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 / JournalEntryService the 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 accountId values.
  • 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, createdBy on create_journal_entry; companyId on the get/post tools), not derived from a session. See §4 Business rules and §9 Gotchas.
  • Implement its own accounting logic — JournalTools / AccountTools are 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 by list_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); each debit/credit is .min(0).

4.2 Double-entry invariants (JournalTools.createEntry, mcp/tools/journal.tools.ts)

Before delegating to journalSvc.addEntry, createEntry enforces:

  1. No negative amountsdebit < 0 || credit < 0Error("Transaction amounts cannot be negative").
  2. One side per linedebit > 0 && credit > 0Error("A transaction line cannot have both debit and credit").
  3. Balanced entryΣ debit must equal Σ credit, else Error("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): after findById, if entry.companyId?.toString() !== companyIdError("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") if status === POSTED; otherwise journalSvc.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, no permission) on both /api/mcp/* routes → ApiRolesGuard requires 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 companyId they 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 entrycreate_journal_entry returns { isError: true, text: "Error: Debits (X) must equal credits (Y)" }; no entry is written.
  • Negative / both-sided lineError: 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 tenantget/post throw Access denied: entry does not belong to this company.
  • Re-postpost_journal_entry on a POSTED entry → Error: Entry <id> is already posted.
  • Unknown SSE sessionPOST /api/mcp/messages returns HTTP 400 { error: "Session not found" }.
  • server.connect throws → the session is removed from the map (no leak) and the error propagates (connectTransport catch).

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/sdkMcpServer + SSEServerTransport (the protocol implementation).
  • zod — tool argument schemas.
  • Finance modules (imported in mcp.module.ts):
    • AccountModuleAccountService (via AccountTools) — chart-of-accounts search.
    • JournalEntryModuleJournalEntryService (via JournalTools) — create/get/post entries.
  • AuthModule, UserModule — imported (forwardRef) to satisfy the ApiRolesGuard / @ApiAuthorize() auth chain on the controller.
  • Module wiring: McpModule provides McpService, AccountTools, JournalTools and registers McpController (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 take companyId, branchId, createdBy as plain arguments. A caller could supply any company id. The only guard is the companyId equality check in getEntry / postEntry — and create_journal_entry has no such check (it writes to whatever companyId it 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_entry directly. Posting is irreversible (no un-post tool).
  • Sessions are in-memory. The transports map is per-process; MCP is not horizontally scalable as written (a POST /messages must hit the same instance that served the GET /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 GENERAL and line type is derived, so the agent cannot create specialized journal kinds or mislabel a leg's direction.
  • Read tool returns lossy account type. AccountTools.listAccounts maps type from account.category?.type ?? "" — accounts without a populated category surface an empty type string.