Skip to content

feat(mcp): local stdio MCP server over the CLI core#24

Merged
vraspar merged 4 commits into
mainfrom
feat/mcp-stdio
Jul 24, 2026
Merged

feat(mcp): local stdio MCP server over the CLI core#24
vraspar merged 4 commits into
mainfrom
feat/mcp-stdio

Conversation

@vraspar

@vraspar vraspar commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes #22.

Adds a tenjin mcp subcommand 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 as structuredContent with a short text summary. The envelope builders are extracted from output.ts into pure functions so the CLI --json path 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: true safe-declines as POLICY_REFUSED, no readline reachable), publish.mode gates publishing (NEEDS_CONFIRMATION details ride structuredContent for the client to render its own confirm UI), and block-severity scan findings stay PUBLISH_BLOCKED with no bypass. Wallet results carry no key material. One MCP-specific default: tenjin_buy returns the body inline, since a pure tool-calling client cannot read a local bodyPath.

@modelcontextprotocol/sdk@1.29.0 lands as a devDependency bundled by tsup, so published dependencies stays {}; 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 fires onclose on 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 under yes: 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

vraspar and others added 2 commits July 23, 2026 19:27
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 A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@vraspar

vraspar commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed in d084251.

1 (arg-surface drift): each tool's input schema is now pinned at compile time with satisfies Record<keyof Args, z.ZodTypeAny> against its core Args type; buy pins against Omit<BuyArgs, 'printBody'> since the adapter forces the body inline. Verified the guard trips on both missing and excess keys.

2 (passphrase vs transport): fixed at the seam rather than documented around. resolveWalletProvider now threads the context's interactivity into the provider (passphrase isTTY forced false when ctx.io.isTTY is false), so a non-interactive context can never reach the raw-mode prompt no matter how tenjin mcp was launched; it fails with the coded no-passphrase error instead. New test asserts the prompt spy is never invoked. One CLI edge changes with this: a piped-stdout run with an encrypted wallet and no keychain/env passphrase source now gets the coded error instead of a hidden TTY prompt, consistent with buy's confirm gate.

3 (README overlap with #19): no change here; both PRs are mine, whichever merges second carries the MCP README section over.

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

A1igator
A1igator previously approved these changes Jul 24, 2026
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>
@vraspar
vraspar merged commit 57f3131 into main Jul 24, 2026
1 check passed
@vraspar
vraspar deleted the feat/mcp-stdio branch July 24, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tenjin mcp: local stdio MCP server over the CLI core

2 participants