feat(mcp): local stdio MCP server over the CLI core#24
Conversation
Expose the CLI's command cores (lookup, inspect, buy, outcome, publish, candidate, wallet) to an MCP client over stdio, in-process. Each of the seven tools builds a fresh CommandContext, calls the core in a try/catch, and returns the exact CLI stdout envelope as structuredContent (a CliError's details is how needs_confirmation / policy-refusal payloads reach the client) plus a short text summary. output.ts gains pure buildSuccessEnvelope/buildFailureEnvelope helpers shared by both the CLI emit functions and the adapter, so the two surfaces cannot drift. Consent lives in the cores: the spend policy gates buy, publish.mode gates publish, hard scan blocks are never bypassable; a non-interactive context (isTTY:false) makes buy safe-decline without a readline. The `mcp` subcommand is its own action (not routed through runCommand) so stdout stays the transport's, and the module is lazily imported so plain CLI startup cost is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StdioServerTransport (SDK 1.29.0) does not listen for stdin end/close, so server.server.onclose never fired on a client disconnect and the process exited only via Node's unsettled top-level-await drain (exit 13 + a TLA warning on every clean session end). Also resolve runMcpServer on stdin end/close, the real end-of-session signal, so `tenjin mcp` exits 0 with no warning; keep onclose for an explicit server.close(). Verified against dist: initialize + tools/list + stdin EOF now exits 0. Extend the stdout-spy test to also drive tenjin_buy and tenjin_publish, so the "nothing but the transport writes to real stdout" guard covers the write paths, not just lookup. Correct the buildCtx comment: the sink only discards ctx.io.stderr writes; settings.ts's default-warn goes to real process.stderr, which is never the MCP wire regardless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A1igator
left a comment
There was a problem hiding this comment.
Review (comment-only; approval is the operator's call).
This is a clean wrap. The strongest call is extracting buildSuccessEnvelope / buildFailureEnvelope as pure functions (src/lib/output.ts) and having both the CLI --json path and the MCP adapter build the wire object through them, so the tool structuredContent cannot drift from the CLI machine contract by construction. Consent is not re-implemented in the server: every tool builds a non-interactive context (buildCtx, isTTY:false, discard-sink stdout/stderr, json:true) and calls the same core, so the gates carry over. All three of the key safety properties are tested (7 pass locally). Findings are about drift-guarding and one stdin edge, not the core design.
Publish gate: not bypassable. With isTTY:false, review mode returns NEEDS_CONFIRMATION with the full payload as structuredContent (tested, server.test.ts "review mode without yes returns NEEDS_CONFIRMATION"), it does not hang and does not silently publish; a block-severity finding stays PUBLISH_BLOCKED even with yes:true (tested, "a block-severity scan finding hard-blocks even with yes:true"). args.yes passes straight through as the documented approval channel, exactly mirroring the CLI --yes (the client re-calls with yes:true after user approval); no new bypass over what an agent driving the CLI already has. Buy is the same story: its confirm reads ctx.io.isTTY (buy.ts:430), hardcoded false here, so a confirm-required spend safe-declines to a refusal envelope with no readline and no payment (tested).
stdout hygiene: enforced. sinkIo() discards the context's stdout/stderr, the SDK StdioServerTransport owns real stdout, and a test spies process.stdout.write and asserts it is never called across a read and a write tool call (server.test.ts:272, expect(spy).not.toHaveBeenCalled()). run.ts also documents the transport as the sole stdout owner. The comment at server.ts:100-103 is honest that some cores write to real process.stderr directly, which is fine since stderr is never the wire.
1. (medium, parity drift) The tool input schemas are hand-copied, with a name guard but no arg-schema guard. The seven tools' inputSchema blocks are zod literals maintained by hand in server.ts, not derived from the core Args interfaces. There is a tool-NAME drift guard ("exposes exactly the seven Tenjin tools"), but nothing asserts each tool's argument surface stays in sync with its core. I checked BuyArgs and PublishArgs against their schemas and they currently match (modulo the deliberate printBody:true / no-bodyPath choice on buy), so there is no live drift, but a core that adds or renames a flag would silently fail to surface over MCP with no test failure. This is the class the tenjin repo closed with the registeredToolNames guard in #446. Add the arg-surface equivalent: a type-level exhaustiveness assertion that each tool's arg mapping covers its core Args keys, or a test enumerating expected keys per tool.
2. (low, stdio edge) The passphrase reader keys off process.stdin.isTTY, not the context. canPrompt in wallet/passphrase.ts:351 uses deps.isTTY ?? Boolean(process.stdin.isTTY), not ctx.io.isTTY. In normal MCP use the client spawns tenjin mcp with piped stdin, so this is false and a passphrase-encrypted wallet with no keychain/env fails cleanly with noPassphraseError rather than reading stdin, leaving the transport sole owner of the fd. But if tenjin mcp is launched in a real terminal (manual debugging), that same wallet state would make the raw-mode passphrase reader and the StdioServerTransport both grab process.stdin and corrupt the protocol stream. Buy avoids this by threading ctx.io.isTTY; the passphrase path is the one core that relies on the launch environment instead of the server's non-interactive intent. Consider forcing the MCP wallet path to a non-prompt resolution, or documenting that tenjin mcp must be spawned with piped stdin.
3. (low, heads-up clash) README overlaps your own open #19, not the operator's #16. merge-tree reports a README.md content conflict between #24 and #19 (the CLI-first restructure) in both orders, since both edit the same region. Operator PR #16 does NOT conflict with #24 (clean). Both #24 and #19 are yours to sequence; flagging so the MCP README section gets carried into whichever merges second.
Secrets/env: the wallet results carry address/source only, never key material (verified by the wallet cores), and the SDK lands as a devDependency so published dependencies stays {} and plain CLI startup is unchanged. The MCP INSTRUCTIONS and tool descriptions correctly frame purchased bodies as untrusted data and tell the client to send only generalized public text to lookup; I read them as agent-facing content and found no embedded steering or injection trap.
…-interactive contexts
Pin each tool's zod inputSchema to its core's Args type with
`satisfies Record<keyof Args, z.ZodTypeAny>` (buy uses Omit<BuyArgs,'printBody'>
since the adapter forces printBody true; candidate guards each action's arg set,
add against CandidateAddArgs and drop against runCandidateDrop's params; wallet
guards the lone action key). An object literal under that clause fails compilation
on a missing OR excess key, so a core that adds or renames a flag now breaks the
build until the tool surface is updated, catching the drift the hand-copied
schemas could not.
Thread context interactivity into resolveWalletProvider: it now passes
passphrase { isTTY: ctx.io.isTTY ? undefined : false } to the local provider, so a
non-interactive context (every `tenjin mcp` context, and any piped-stdout run) can
never start a hidden-input passphrase prompt that would fight the MCP stdio
transport for stdin; it fails with the coded no-passphrase error instead. A real
TTY keeps the resolver's process.stdin.isTTY default. This mirrors buy's existing
confirm gate. Behavior change for one CLI edge: a piped-stdout invocation with an
encrypted wallet and no keychain/env passphrase now gets the coded error rather
than a hidden TTY prompt, consistent with the confirm gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three addressed in d084251. 1 (arg-surface drift): each tool's input schema is now pinned at compile time with 2 (passphrase vs transport): fixed at the seam rather than documented around. 3 (README overlap with #19): no change here; both PRs are mine, whichever merges second carries the MCP README section over. |
A1igator
left a comment
There was a problem hiding this comment.
Round-2 delta (d084251, comment-only). Two of three items resolved; the README conflict is real and still open.
Medium (arg-schema drift guard): resolved, and better than a runtime guard. Each tool's input schema is now a named const pinned with satisfies Record<keyof Args, z.ZodTypeAny> (src/mcp/server.ts), e.g. lookupInput satisfies Record<keyof LookupArgs, ...>, publishInput satisfies Record<keyof PublishArgs, ...>. Because an object literal under that clause fails on both a missing and an excess key, a core that adds, renames, or drops a flag breaks tsc here until the tool surface is updated. That is stronger than the #446-style boot guard I suggested (it fails the build, not just a test), and the deliberate divergences are explicit: buyInput satisfies Record<keyof Omit<BuyArgs, 'printBody'>, ...> with a reason, candidate decomposed into per-action CandidateAddArgs / runCandidateDrop param guards. pnpm typecheck passes, which also proves there is no current drift.
Low 1 (passphrase gating on process.stdin.isTTY): resolved. resolveWalletProvider now threads passphrase: { isTTY: ctx.io.isTTY ? undefined : false } (src/lib/wallet/index.ts), so a non-interactive context (every tenjin mcp context, isTTY:false) can never trigger the hidden-input passphrase reader and instead fails with the coded no-passphrase error, closing the stdin-contention edge against the stdio transport. A real TTY passes isTTY:undefined, leaving the CLI path unchanged. This mirrors buy's confirm gate exactly, and the comment says so; new wallet/local.test.ts coverage.
Low 2 (README conflict with merged #19): unaddressed. The delta does not touch README, and the PR genuinely conflicts against current main: git merge-tree origin/main d084251 and a real test merge both report CONFLICT (content) in README.md only (#19's restructure merged after this branch's README hunk). GitHub's DIRTY/CONFLICTING is accurate, not a stale recompute. Needs a main merge/rebase resolving the MCP README section into the current text.
Invariants intact. The server.ts change is purely the extraction of the inline inputSchema literals into the guarded consts; runCore, buildCtx (isTTY:false, sink stdout/stderr), and the per-tool action handlers are untouched. The publish gate (review -> NEEDS_CONFIRMATION, hard block non-bypassable) and the stdout-never-written guard still pass (23 MCP + wallet tests green). No drift in what round 1 verified.
Resolves the README clash with the #19 restructure: keeps main's layout, grafts the Local stdio MCP server section in as a top-level section, and updates the intro roadmap line to current truth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #22.
Adds a
tenjin mcpsubcommand that speaks MCP over stdio and calls the existing command cores in process, no shelling out. Registration:claude mcp add tenjin -s user -- tenjin mcp.Seven tools mirroring the CLI:
tenjin_lookup,tenjin_inspect,tenjin_buy,tenjin_outcome,tenjin_publish,tenjin_candidate,tenjin_wallet. Each call builds a fresh non-TTY CommandContext, runs the core, and returns the machine JSON envelope asstructuredContentwith a short text summary. The envelope builders are extracted fromoutput.tsinto pure functions so the CLI--jsonpath and MCP share one contract by construction.Consent semantics carry over unchanged because they live in the cores: spend policy gates buying (a confirm-required buy without
yes: truesafe-declines asPOLICY_REFUSED, no readline reachable),publish.modegates publishing (NEEDS_CONFIRMATIONdetails ridestructuredContentfor the client to render its own confirm UI), and block-severity scan findings stayPUBLISH_BLOCKEDwith no bypass. Wallet results carry no key material. One MCP-specific default:tenjin_buyreturns the body inline, since a pure tool-calling client cannot read a localbodyPath.@modelcontextprotocol/sdk@1.29.0lands as a devDependency bundled by tsup, so publisheddependenciesstays{}; the mcp module is a lazy code-split chunk and plain CLI startup is unchanged. The second commit fixes clean shutdown: the SDK's stdio transport never firesoncloseon stdin EOF, so the server now also resolves on stdin end/close (verified exit 0, previously exit 13 with an unsettled top-level-await warning).Tested with the SDK client over
InMemoryTransport: tool list, lookup envelope, buy decline with the payment path never invoked, buy settle with inline body, publish confirmation round trip, publish hard block underyes: true, and a guard that only the transport writes to real stdout across read and write paths. Full suite 691 passed, typecheck/lint/build clean, plus a live stdio smoke against the built binary.🤖 Generated with Claude Code