Wire MCP server support via completion({ mcp }) - #80
Conversation
Adds src/mcp.mjs: optional MCP server wiring for QVAC's completion({ mcp })
path, alongside (not replacing) the existing v0 JSON-action protocol.
- src/mcp.mjs: loadMcpConfig() reads an optional mcp.json (same
optional-file/NAD_*-override pattern as policy.json/address-book.json) —
a list of MCP servers to spawn over stdio. connectMcpServers() connects
each with the official @modelcontextprotocol/sdk, best-effort (a server
that fails to start is skipped and reported, not fatal — same rule as a
failed model load). summarizeMcpToolResult() turns an arbitrary MCP tool
result into a bounded string for history/terminal.
- src/agent.mjs: completeWithMcp() runs the full agentic loop QVAC's own
mcp-websearch example requires by hand — stream text, collect tool calls,
invoke them, push { role: "assistant" } then { role: "tool" } turns back
into history, and re-complete — until the model stops calling tools or a
finite maxToolRounds is hit. Takes an injectable runCompletion for tests.
- src/cli.mjs: when MCP servers are configured and connected, natural-language
turns route through completeWithMcp() instead of the v0 path. Every tool
call is gated behind the same confirm + mainnet-ack prompt every wallet
write already goes through — the tool catalog is discovered at runtime
from an arbitrary server, so there's no way to tell a read from a write
the way isWrite() does for the built-in actions, and asking every time is
the safe default. Tool errors and a hit round limit are surfaced, not
swallowed.
@tetherto/wdk-mcp-toolkit (the 35-tool wallet server named in portdeveloper#16 and the
README's "Upgrade path") is still a reserved placeholder on npm (0.0.0, no
code) as of this PR, so this wires any MCP server generically over stdio
instead — pointing mcp.json at the real package once it ships needs no code
changes here. portdeveloper#15 (native tool-calling via completion({ tools })) is being
worked on separately in portdeveloper#77; this does not touch that path or its files.
312 tests pass (17 new: mcp.test.mjs covers config validation, result
summarization, and connectMcpServers()'s best-effort failure handling
against the real MCP SDK; agent-mcp.test.mjs drives completeWithMcp()'s
loop end-to-end — follow-up turns, multi-call rounds, toolError surfacing,
round-limit enforcement, and stream-error tolerance — via an injected fake
completion() rather than asserting dispatch is a function). npm run build
succeeds; npm run doctor passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@portdeveloper After #15 is closed will make the necessary changes as required |
portdeveloper
left a comment
There was a problem hiding this comment.
Thanks for taking this on. There is one secret-handling blocker in src/mcp.mjs:112-116: every MCP child receives { ...process.env, ...s.env }. In the normal agent process that includes WDK_SEED, PIMLICO_API_KEY, and any other wallet credentials, so an arbitrary configured server or npx package can read them before the first tool confirmation.
Please start from the MCP SDK's minimal/default child environment and add only the variables explicitly listed in that server's env config. Add a regression that places sentinel wallet secrets in process.env, constructs the transport options through an injectable seam, and proves those values are absent unless the operator explicitly configured them for that server.
src/mcp.mjs previously spawned every configured MCP server with
{ ...process.env, ...s.env } — the full parent environment, including
WDK_SEED, PIMLICO_API_KEY, and every other wallet credential this agent
holds, readable by an arbitrary configured server (often an `npx` package
this repo does not control) before the first tool confirmation ever runs.
- buildStdioTransportOptions(server) is the new, exported seam: it starts
from @modelcontextprotocol/sdk's own getDefaultEnvironment() (the SDK's
minimal, safe allowlist — HOME/LOGNAME/PATH/SHELL/TERM/USER on POSIX) and
layers on only the variables explicitly listed in that server's own `env`
block in mcp.json. process.env is never spread in. connectMcpServers()
now builds transport options through this function instead of inlining
the (buggy) merge.
- test/mcp.test.mjs: a regression suite that plants sentinel secrets
(WDK_SEED, PIMLICO_API_KEY) in process.env before each test and proves
buildStdioTransportOptions() never forwards them unless the operator
named them explicitly in that server's own env — both through an
injected getDefaultEnv seam (fast, no SDK dependency) and against the
real MCP SDK's own default-env function.
- .env.example / src/mcp.mjs doc comments now say plainly that a server's
env is additive, not inherited.
315 tests pass; npm run build succeeds.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@portdeveloper Fixed in dd3a77f. The child no longer gets
315 tests pass, |
What this changes
Wires QVAC to Model Context Protocol servers via
completion({ mcp }), alongside (notreplacing) the existing v0 JSON-action protocol.
Closes #16
A blocker worth stating up front
@tetherto/wdk-mcp-toolkit— the 35-tool wallet server this issue names — is still areserved placeholder on npm (
0.0.0, no code) as of this PR. I checked the publishedtarball directly: it's a 54-byte
package.jsonand nothing else. So "wire the officialpackage" isn't literally possible yet.
What's here instead:
src/mcp.mjswires any MCP server generically over stdio, using thesame protocol QVAC's own
mcp-websearchexample (@qvac/sdk@0.14.1) uses. The moment@tetherto/wdk-mcp-toolkitships, pointingmcp.json'scommand/argsat it works withzero code changes here.
Relationship to #15
#16 names #15 (native tool-calling via
completion({ tools })) as its precursor, and #77is mid-review for that. I did not touch #77's territory — no changes to the
tools-basednative tool-calling path,
dispatchToolCall, oruseNativeTools.completion({ mcp })isa separate QVAC parameter with its own tool-call loop (stream → collect calls → invoke →
push
assistant/toolturns → re-complete), so this PR implements that loop only for theMCP path — it doesn't touch or duplicate #77's
toolspath.Changes
src/mcp.mjs(new) —loadMcpConfig()reads an optionalmcp.json(sameoptional-file /
NAD_*-override pattern aspolicy.json/address-book.json): a listof MCP servers to spawn over stdio.
connectMcpServers()connects each with the official@modelcontextprotocol/sdk, best-effort — a server that fails to start is skipped andreported via
onWarn, not fatal (same rule as a failed model load: the rest of the agentstill works).
summarizeMcpToolResult()turns an arbitrary MCP tool result into a boundedstring for history/terminal.
src/agent.mjs—completeWithMcp(): streams text, collects tool calls viarun.final, invokes them, pushes{ role: "assistant" }then{ role: "tool" }turnsback into history, and re-completes — until the model stops calling tools or a finite
maxToolRounds(default 8) is hit. Never throws on a model-side error (same rule ascomplete()); returns{ text, rounds, toolErrors, limitReached }so the caller decideshow to report a stopped model, a tool error, or a hit round limit. Takes an injectable
runCompletionso the loop is unit-testable without a live model.src/cli.mjs— when MCP servers are configured and connected, natural-language turnsroute through
completeWithMcp()instead of the v0 path. Every tool call is gated behindthe same confirm + mainnet-ack prompt every wallet write already goes through: the tool
catalog is discovered at runtime from an arbitrary server, so there's no reliable way to
tell a read from a write the way
isWrite()does for the built-in actions — asking everytime is the safe default here, not a guess. Tool errors and a hit round limit are printed
and, in scripted mode, flip the exit code — not swallowed.
.env.example/.gitignore/README.md/CLAUDE.mdupdated to documentmcp.json,NAD_MCP_CONFIG, and the actual state of the@tetherto/wdk-mcp-toolkitdependency.package.json— added@modelcontextprotocol/sdk(pure JS, no native deps, bundles fineunder esbuild — verified by
npm run build).How I tested it
npm run buildsucceedsnpm run doctorpassesnpm test— 312 tests pass, 17 new:test/mcp.test.mjs—loadMcpConfig()validation (malformed JSON, unknown keys,missing name/command, non-string args/env, duplicate names),
summarizeMcpToolResult()shape handling and truncation, and
connectMcpServers()'s best-effort failure handlingdriven against the real
@modelcontextprotocol/sdk(a nonexistent binary is skippedand reported, not thrown; one bad server doesn't block another).
test/agent-mcp.test.mjs— drivescompleteWithMcp()'s loop end-to-end through aninjected fake
runCompletion(the actual{ events, final }shapecompletion()returns): follow-up turns after a tool call, multi-call rounds invoked in order,
toolErrorevents surfaced rather than swallowed, round-limit enforcement, andstream-error tolerance. This exercises the loop itself, not just that a function exists.
environment (
@qvac/llm-llamacppnative module isn't resolving here — looks like asandbox/prebuild issue, unrelated to this change) so I could not do an end-to-end
interactive run against a live MCP server. Everything above is verified without the model.
Model / platform tested on: none (see above) —
npm test+npm run build+npm run doctoronly, in a Linux/macOS CI-like sandbox without a working QVAC model runtime.
Scope check
mcp.json= byte-identical behavior to before); on only when a user writes one.Conventions
npm(not pnpm/yarn) and did not add a globalsodium-nativeoverride..env, seeds, keys, or model weights.