Skip to content

Wire MCP server support via completion({ mcp }) - #80

Merged
portdeveloper merged 2 commits into
portdeveloper:mainfrom
codeswithroh:feat/mcp-server-wiring
Sep 7, 2026
Merged

portdeveloper merged 2 commits into
portdeveloper:mainfrom
codeswithroh:feat/mcp-server-wiring

Conversation

@codeswithroh

@codeswithroh codeswithroh commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Wires QVAC to Model Context Protocol servers via completion({ mcp }), alongside (not
replacing) 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 a
reserved placeholder on npm (0.0.0, no code) as of this PR. I checked the published
tarball directly: it's a 54-byte package.json and nothing else. So "wire the official
package" isn't literally possible yet.

What's here instead: src/mcp.mjs wires any MCP server generically over stdio, using the
same protocol QVAC's own mcp-websearch example (@qvac/sdk@0.14.1) uses. The moment
@tetherto/wdk-mcp-toolkit ships, pointing mcp.json's command/args at it works with
zero code changes here.

Relationship to #15

#16 names #15 (native tool-calling via completion({ tools })) as its precursor, and #77
is mid-review for that. I did not touch #77's territory — no changes to the tools-based
native tool-calling path, dispatchToolCall, or useNativeTools. completion({ mcp }) is
a separate QVAC parameter with its own tool-call loop (stream → collect calls → invoke →
push assistant/tool turns → re-complete), so this PR implements that loop only for the
MCP path — it doesn't touch or duplicate #77's tools path.

Changes

  • src/mcp.mjs (new) — 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 via onWarn, not fatal (same rule as a failed model load: the rest of the agent
    still works). summarizeMcpToolResult() turns an arbitrary MCP tool result into a bounded
    string for history/terminal.

  • src/agent.mjs — completeWithMcp(): streams text, collects tool calls via
    run.final, invokes them, pushes { role: "assistant" } then { role: "tool" } turns
    back 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 as
    complete()); returns { text, rounds, toolErrors, limitReached } so the caller decides
    how to report a stopped model, a tool error, or a hit round limit. Takes an injectable
    runCompletion so the loop is unit-testable without a live model.

  • 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 reliable way to
    tell a read from a write the way isWrite() does for the built-in actions — asking every
    time 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.md updated to document mcp.json,
    NAD_MCP_CONFIG, and the actual state of the @tetherto/wdk-mcp-toolkit dependency.

  • package.json — added @modelcontextprotocol/sdk (pure JS, no native deps, bundles fine
    under esbuild — verified by npm run build).

How I tested it

  • npm run build succeeds
  • npm run doctor passes
  • npm 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 handling
      driven against the real @modelcontextprotocol/sdk (a nonexistent binary is skipped
      and reported, not thrown; one bad server doesn't block another).
    • test/agent-mcp.test.mjs — drives completeWithMcp()'s loop end-to-end through an
      injected fake runCompletion (the actual { events, final } shape completion()
      returns): follow-up turns after a tool call, multi-call rounds invoked in order,
      toolError events surfaced rather than swallowed, round-limit enforcement, and
      stream-error tolerance. This exercises the loop itself, not just that a function exists.
  • Real gasless send / interactive MCP session: I couldn't get a model to load in this
    environment (@qvac/llm-llamacpp native module isn't resolving here — looks like a
    sandbox/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 doctor
only, in a Linux/macOS CI-like sandbox without a working QVAC model runtime.

Scope check

  • This stays within v0 scope, OR
  • This grows the scope: adds an opt-in MCP tool-calling path. Off by default (no
    mcp.json = byte-identical behavior to before); on only when a user writes one.

Conventions

  • I used npm (not pnpm/yarn) and did not add a global sodium-native override.
  • I did not commit .env, seeds, keys, or model weights.

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

Copy link
Copy Markdown
Contributor Author

@portdeveloper After #15 is closed will make the necessary changes as required

@portdeveloper portdeveloper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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

codeswithroh commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor Author

@portdeveloper Fixed in dd3a77f.

The child no longer gets process.env at all. buildStdioTransportOptions(server) (new, exported from src/mcp.mjs) 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 whatever that server's own env block in mcp.json explicitly names. connectMcpServers() now goes through that function instead of inlining the merge.

test/mcp.test.mjs has the regression you asked for: it plants sentinel values for WDK_SEED/PIMLICO_API_KEY in process.env before each test, then proves buildStdioTransportOptions() never forwards them unless the operator names them explicitly for that server once with an injected getDefaultEnv seam (fast, no SDK dependency) and once against the real SDK's own default-env function, plus a third case confirming explicit opt-in still works when a secret is named.

315 tests pass, npm run build succeeds.

@portdeveloper portdeveloper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

looks good, thanks

@portdeveloper
portdeveloper merged commit d76c984 into portdeveloper:main Sep 7, 2026
1 check passed
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.

Integrate @tetherto/wdk-mcp-toolkit (35 tools)

2 participants