Architecture
The monorepo layout, the DTO contract, and the invariants that hold sandbox and live together.
Monorepo layout
Satchel MCP is four independent npm projects — there is no root package.json; each
subdirectory installs and runs on its own:
app/ Next.js 15 (App Router, React 19, Tailwind v4).
Landing page + the sandbox API (app/api/sandbox/*) + waitlist +
the remote MCP streamable-HTTP route (app/[transport]/route.ts) +
an OAuth 2.1 authorization server + 2FA login + a /health endpoint.
mcp/ TypeScript stdio MCP server, three source files by design:
src/client.ts — HTTP + auth + typed DTOs
src/tools.ts — the 6 shared read tools + formatting (also consumed
by the remote route in app/)
src/index.ts — stdio server + satchel_move_money + widget wiring
plus bin/connect.mjs (the connect wizard) and src/connect.mjs (the
shared per-client config generator both the wizard and the landing
page import from).
spec/ The real API swagger specs + CONTRACT.md, the shared build contract.
Swagger files are never edited by hand.
video/ Remotion video projects.The DTO shapes are load-bearing
The sandbox (app/api/sandbox/_data.ts), the MCP client's TypeScript types
(mcp/src/client.ts), and the real swagger all agree field-for-field — deliberately,
because flipping from sandbox to live is a URL-and-credentials change with no code
change (see Overview). DTO field
names are never "improved" or renamed for cosmetic reasons; a rename would break that
guarantee.
When the build contract document and the real swagger disagree, the swagger wins. A
few corrections were already applied after verifying against the real UAT API, for example:
payment_state is EXECUTED | REJECTED | PROCESSING (not COMPLETED); payment_type uses
the real API's full enum, not an invented shorthand; and MoneyDTO.amount is unsigned with
direction carried by payment_role, not by a DEBIT/CREDIT enum (see below).
Money is unsigned; direction comes from payment_role
MoneyDTO.amount is always positive. Direction is a separate signal:
payment_role: "SENDER" means money out, "RECIPIENT" means money in. This is never
inferred from the sign of the amount — the sign is derived from the role, for display
only, never read from the number. This single rule drives every balance, flow, and
summary computation across both the sandbox and the MCP tools; see
Tools overview for
how it surfaces in structuredContent.
Auth flow
In live mode, the plain Client Office password is SHA-512 encoded client-side before being
sent. Authentication is POST /token with X-Username / X-Password headers; the API
returns the usable API token in the X-Auth-Token response header (a similarly-named field
in the response body, session, is not accepted by read endpoints against the live API —
the client keeps that as a sandbox-only fallback). Subsequent calls resend the token as
X-Auth-Token. On a 401, the client re-authenticates once and retries automatically.
Session header
The header name is X-Auth-Token by default (also SATCHEL_SECURITY_HEADER's default),
matching what the live UAT API expects. It remains configurable, for other Client Office
gateways that might expect a differently-named header. The sandbox accepts this header name
plus a couple of legacy aliases, case-insensitively.
Sandbox state is in-memory
The sandbox keeps all account, card, and transaction state in a process-wide in-memory store; it reseeds on every server restart. There is no database. Waitlist-issued sandbox API keys are the one exception — they persist to a local JSON file so a key survives a restart.
MCP Apps widgets are additive, never load-bearing
Widget HTML (see Widgets) is advertised via _meta
on top of tools that always also return text and structuredContent. A text-only client
gets full functionality with no widget rendering at all — no tool's core behaviour depends
on whether a client happens to render its optional widget.
Read tools: one implementation, two hosts
The 6 read tools' handlers live in a single shared TypeScript module, consumed by both the
local stdio server and the hosted remote server (which registers the exact same handlers
against a per-session client resolved from the caller's bearer token). There is no second,
divergent implementation of "what does satchel_get_balance do" sitting behind the remote
endpoint — one set of pure functions, two transports.