MCP Server — let an AI agent post journal entries into zerp
The whole MCP module reduces to one idea:
zerp embeds a Model Context Protocol server so an external LLM agent (Claude, Cursor, etc.) can read the chart of accounts and create / inspect / post double-entry journal entries — over an SSE transport, behind the normal API auth guard, delegating to the same
AccountService/JournalEntryServicethe GraphQL API uses. It exposes exactly four tools, no more: it is a thin, finance-only agent surface, not a general "do anything to zerp" bridge.
Source: BE src/modules/mcp (incl. mcp/tools/) · Admin — N/A (no admin UI; the client is an external MCP-capable AI tool)
1. Purpose & scope
The mcp module turns the journal-entry sub-slice of the finance domain into MCP tools an AI agent can call:
- Discover accounts — search the chart of accounts to find
accountIdvalues. - Draft a journal entry — create a balanced,
SAVED(draft) double-entry posting. - Review an entry — fetch a single entry by id for the human to confirm.
- Post an entry — finalize a draft (irreversible) once the human has confirmed.
It deliberately does NOT:
- Expose any other zerp domain (no inventory, sales, HR, payroll, CRM tools — only
list_accounts+ the three journal tools). - Edit or delete journal entries (only create / get / post).
- Resolve company / branch / user context itself — those identifiers are passed in as tool arguments by the calling agent (
companyId,branchId,createdByoncreate_journal_entry;companyIdon the get/post tools), not derived from a session. See §4 Business rules and §9 Gotchas. - Implement its own accounting logic —
JournalTools/AccountToolsare adapters that delegate to the canonical finance/journal, finance/account, and finance/transaction services.
The MCP server identifies itself to clients as { name: "zyncount", version: "1.0.0" } (mcp.service.ts onModuleInit).
2. Data model
The MCP module owns no collection of its own. It is stateless except for in-memory transport sessions:
| State | Where | Lifetime |
|---|---|---|
McpServer instance |
McpService.server |
One per process, built in onModuleInit(). |
transports: Map<sessionId, SSEServerTransport> |
McpService.transports |
One entry per open SSE connection; removed on close or on a failed connect. |
All persisted data lives in the finance collections it writes through:
finance_journal_entries— the journal entry header (see finance/journal).finance_account_transactions— the debit/credit legs (see finance/transaction).finance_accounts— read-only here, searched bylist_accounts(see finance/account).
The only module-local shape is the account summary returned to the agent (mcp/tools/account.tools.ts):
export interface IAccountSummary {
id: string; // account _id (use this as accountId in create_journal_entry)
name: string; // account.accountName
number: string; // account.accountNumber
type: string; // account.category?.type ?? ""
}3. API surface
3.1 Transport (REST over SSE)
The MCP transport is two HTTP endpoints on a NestJS controller, both behind @ApiAuthorize() (mcp/mcp.controller.ts), mounted under api/mcp:
| Method | Route | Purpose | Auth |
|---|---|---|---|
GET |
/api/mcp/sse |
Open the Server-Sent-Events stream. McpService.connectTransport(res) creates an SSEServerTransport("/api/mcp/messages", res), stores it by transport.sessionId, and server.connect(transport). Cleanup hooked on the response close event. |
@ApiAuthorize() |
POST |
/api/mcp/messages |
Receive a JSON-RPC MCP message. McpService.handleMessage(req, res) looks up the transport by req.query.sessionId and calls transport.handlePostMessage(req, res, req.body). Returns 400 { error: "Session not found" } if the sessionId is unknown. |
@ApiAuthorize() |
@ApiAuthorize() (auth/decorators/api-auth.decorator.ts) applies the ApiRolesGuard and sets the roles / authNotRequired / ignoreCompanyQuery / CHECK_PERMISSION metadata. Here it is called with no options, so: auth is required (a valid token), but no specific role or permission is demanded — any authenticated principal that can reach the endpoint can open the MCP stream. See platform/auth and platform/permissions-access.
There is no GraphQL surface for this module — MCP speaks its own JSON-RPC protocol over the two REST endpoints above.
3.2 The four MCP tools
Registered in McpService.registerTools() via server.tool(name, description, zodSchema, handler). Each handler returns { content: [{ type: "text", text }] }, or { ..., isError: true } on failure.
| Tool | Args (Zod) | Delegates to | Returns (text) |
|---|---|---|---|
list_accounts |
keyword?: string (case-insensitive name filter) |
AccountTools.listAccounts(keyword) → accountSvc.find({ keyword }) |
JSON array of IAccountSummary |
create_journal_entry |
description: string, documentDate?: number (ms, default now), companyId: string, branchId?: string, createdBy: string, transactions: [{ accountId, debit≥0, credit≥0, remark? }] (min 2 lines) |
JournalTools.createEntry(companyId, branchId, createdBy, input) → journalSvc.addEntry(...) |
Entry created summary: ref, _id, Status: SAVED, description + a "confirm before posting" prompt |
get_journal_entry |
id: string, companyId: string |
JournalTools.getEntry(id, companyId) → journalSvc.findById(id) (+ company check) |
JSON of the entry, or Entry <id> not found. (isError) |
post_journal_entry |
id: string, companyId: string |
JournalTools.postEntry(id, companyId) → journalSvc.postJournalEntry(id) |
Entry <id> has been posted successfully. |
The tool descriptions are part of the contract — they instruct the agent on the intended human-in-the-loop flow, e.g. create_journal_entry: "Always show the entry to the user and ask for confirmation before calling post_journal_entry"; post_journal_entry: "This action is irreversible. Only call after the user has confirmed." This is the only enforcement of confirmation — see §9 Gotchas.
4. Business rules & validation
4.1 Argument validation (Zod, at the MCP boundary)
create_journal_entry.transactions—.min(2)(at least two lines); eachdebit/creditis.min(0).
4.2 Double-entry invariants (JournalTools.createEntry, mcp/tools/journal.tools.ts)
Before delegating to journalSvc.addEntry, createEntry enforces:
- No negative amounts —
debit < 0 || credit < 0→Error("Transaction amounts cannot be negative"). - One side per line —
debit > 0 && credit > 0→Error("A transaction line cannot have both debit and credit"). - Balanced entry —
Σ debitmust equalΣ credit, elseError("Debits (X) must equal credits (Y)").
It then builds the entry and posts it through the finance service as a draft:
return this.journalSvc.addEntry({
type: JournalEntryTypes.GENERAL,
description: input.description,
documentDate: input.documentDate ?? Date.now(),
status: AccountTransactionStatus.SAVED, // ← created as a DRAFT
companyId, branchId, createdBy,
transactions: input.transactions.map((t) => ({
accountId: t.accountId,
debit: t.debit,
credit: t.credit,
remark: t.remark,
type: t.debit > 0 // line type derived from which side is non-zero
? AccountTransactionTypes.DEBIT
: AccountTransactionTypes.CREDIT,
})),
});- Entry type is always
GENERAL— the agent cannot create specialized journal kinds. - Line
type(DEBIT/CREDIT) is derived from which amount is non-zero, not supplied by the agent.
4.3 Tenant isolation (getEntry / postEntry)
Because the MCP layer has no session-bound company, the companyId argument is the tenant fence:
getEntry(id, companyId): afterfindById, ifentry.companyId?.toString() !== companyId→Error("Access denied: entry does not belong to this company").postEntry(id, companyId): same company check;Error("Journal entry <id> not found")if missing;Error("Entry <id> is already posted")ifstatus === POSTED; otherwisejournalSvc.postJournalEntry(id).
4.4 State machine
create_journal_entry ─▶ JournalEntry { status: SAVED } (draft, balanced)
│
get_journal_entry ◀───────────┤ (human reviews)
▼
post_journal_entry ─▶ JournalEntry { status: POSTED } (irreversible; GL legs finalized)
There is no edit/delete tool — to change a draft, the human uses the regular finance UI/API.
4.5 Side effects
Posting flows entirely through JournalEntryService.postJournalEntry — the GL legs, totals recomputation, and any audit are the finance module's responsibility, not the MCP module's. See finance/journal §business rules.
5. Permissions
- Transport guard:
@ApiAuthorize()(no roles, nopermission) on both/api/mcp/*routes →ApiRolesGuardrequires a valid authenticated principal but checks no specific permission module/action. (mcp/mcp.controller.ts,auth/decorators/api-auth.decorator.ts.) - Tool-level: the four tools carry no per-tool permission check beyond the company-id fence in the journal tools. Any caller who can open the SSE stream can call any of the four tools for any
companyIdthey pass. There is no CASL ability or finance-permission gate inside the tools (unlike the GraphQL resolvers, which use@ApGqlAuthorize({ permission: ... })).
This is a notable deviation from the rest of zerp, where finance mutations are gated by explicit permission subjects/actions. See §9 Gotchas and platform/permissions-access.
6. Flows
6.1 Agent posts a journal entry (happy path)
1. AI client opens stream → GET /api/mcp/sse (token in header → ApiRolesGuard passes)
McpService.connectTransport: store transport by sessionId, server.connect
2. Client lists capabilities → POST /api/mcp/messages?sessionId=... (MCP handshake / tools/list)
3. Agent finds an account → call list_accounts { keyword: "bank" }
→ AccountTools.listAccounts → accountSvc.find({keyword})
→ returns [{ id, name, number, type }, ...]
4. Agent drafts the entry → call create_journal_entry { description, companyId, createdBy,
transactions: [ {accountId, debit:100, credit:0},
{accountId, debit:0, credit:100} ] }
→ JournalTools.createEntry: validate (≥2 lines, no negatives,
one side per line, Σdebit==Σcredit) → journalSvc.addEntry(status=SAVED)
→ text: "Entry created … Ref … Status: SAVED … confirm before posting."
5. Human reviews → (optional) call get_journal_entry { id, companyId } → JSON of entry
6. Human confirms; agent posts→ call post_journal_entry { id, companyId }
→ JournalTools.postEntry: company check, not-already-posted check
→ journalSvc.postJournalEntry(id) → status POSTED, GL finalized
→ text: "Entry <id> has been posted successfully."
7. Client disconnects → res "close" event → transports.delete(sessionId)
6.2 Unhappy paths
- Unbalanced entry →
create_journal_entryreturns{ isError: true, text: "Error: Debits (X) must equal credits (Y)" }; no entry is written. - Negative / both-sided line →
Error: Transaction amounts cannot be negative/Error: A transaction line cannot have both debit and credit. - < 2 lines → rejected by the Zod
.min(2)schema before the handler runs. - Wrong tenant →
get/postthrowAccess denied: entry does not belong to this company. - Re-post →
post_journal_entryon aPOSTEDentry →Error: Entry <id> is already posted. - Unknown SSE session →
POST /api/mcp/messagesreturns HTTP 400{ error: "Session not found" }. server.connectthrows → the session is removed from the map (no leak) and the error propagates (connectTransportcatch).
7. Admin UI
N/A. There is no zerp-admin screen for MCP. The "UI" is whatever external MCP-capable AI client (Claude Desktop, Cursor, an agent runtime, etc.) connects to /api/mcp/sse. The human-in-the-loop confirmation lives in that client, prompted by the tool descriptions.
8. Dependencies & integrations
@modelcontextprotocol/sdk—McpServer+SSEServerTransport(the protocol implementation).zod— tool argument schemas.- Finance modules (imported in
mcp.module.ts):AccountModule→AccountService(viaAccountTools) — chart-of-accounts search.JournalEntryModule→JournalEntryService(viaJournalTools) — create/get/post entries.
AuthModule,UserModule— imported (forwardRef) to satisfy theApiRolesGuard/@ApiAuthorize()auth chain on the controller.- Module wiring:
McpModuleprovidesMcpService,AccountTools,JournalToolsand registersMcpController(mcp/mcp.module.ts). - No cron/jobs, no external HTTP calls of its own (it is called by an external agent; contrast with zoom.md, which calls out to Zoom).
9. Gotchas & project-specific rules
- Context is passed in, not derived. Unlike every GraphQL resolver (which reads
contextSvc.companyId/userId), the MCP tools takecompanyId,branchId,createdByas plain arguments. A caller could supply any company id. The only guard is thecompanyIdequality check ingetEntry/postEntry— andcreate_journal_entryhas no such check (it writes to whatevercompanyIdit is given). Treat the transport-level auth as the real boundary and lock it down. - No per-tool permission gate.
@ApiAuthorize()here requires only authentication, not a finance permission. This is laxer than the equivalent GraphQL path. If you port/harden this, add a permission check inside the tools or tighten the controller decorator. - Confirmation is advisory only. The "ask the user before posting" rule lives in the tool description text, not in code. A misbehaving agent can call
post_journal_entrydirectly. Posting is irreversible (no un-post tool). - Sessions are in-memory. The
transportsmap is per-process; MCP is not horizontally scalable as written (aPOST /messagesmust hit the same instance that served theGET /sse). No sticky-session/affinity is configured here. - Server name is
"zyncount"(the historical project name), not"zerp"— cosmetic, but visible to MCP clients. - Entry type is fixed to
GENERALand linetypeis derived, so the agent cannot create specialized journal kinds or mislabel a leg's direction. - Read tool returns lossy account
type.AccountTools.listAccountsmapstypefromaccount.category?.type ?? ""— accounts without a populated category surface an empty type string.