Skip to content

Keep built-in wallet tools working with an MCP server connected - #120

Merged
portdeveloper merged 2 commits into
portdeveloper:mainfrom
Yash-arch-ui:fix/118-mcp-builtin-tools
Oct 5, 2026
Merged

portdeveloper merged 2 commits into
portdeveloper:mainfrom
Yash-arch-ui:fix/118-mcp-builtin-tools

Conversation

@Yash-arch-ui

Copy link
Copy Markdown
Collaborator

Closes #118

What was wrong

With an mcp.json present, processLine sent every line to completeWithMcp, which completed with { mcp } alone. The nine built-in actions the system prompt advertises were never offered to the model, so a wallet request was a no-op — and a scripted run still exited 0. A built-in call the model did make was answered by the server's tool catalog instead of our safety boundary (Refused: no handler is registered for MCP tool "get_address").

What changed

  • src/agent.mjs — completeWithMcp now takes the agent's own tools alongside mcp (the SDK merges both lists into one request; an empty list keeps the request byte-identical to the MCP-only shape it replaces) and routes by name: built-in names go to invokeNativeToolCall, server names to the existing invokeToolCall gate.
  • src/nativeToolLoop.mjs — dispatchNativeCall is the seam both turn drivers share. Writes (send_mon, send_token, transfer_nft, swap) and account-with-index go through the same handleAction a slash command uses (resolve → policy → preview → mainnet ack → y/N); reads go through dispatchToolCall. boundary says who printed, so results are shown once, not twice or never. Refusals, tool errors and a hit round limit all fold into hadFailure.
  • src/nativeToolLoop.mjs — runMcpTurn is the new MCP turn driver, living beside runNativeToolLoop; src/cli.mjs only passes in the handles it needs, same as the native loop. invokeMcpToolCall is exported next to handleAction so the gate is testable.

The MCP confirmation gate is untouched: every server tool still asks, and a declined call never reaches the server.

Tests

Coverage of the combined CLI dispatch offline (only the model is faked — no live model, no GPU, no funded wallet):

  • test/mcp-native-dispatch.test.mjs (13) — completion receives tools and mcp on every round; a built-in write reaches the real handleAction (refused, exit 1) and never the gate; a read goes through dispatchToolCall, prints once, and a refusal still fails the run; MCP calls route to the gate and nowhere else; the gate refuses an unapproved call; toolError and round limit fail the run; routing unit tests for dispatchNativeCall.
  • test/mcp-cli.test.mjs (5) — dist/cli.mjs end to end with a real stdio MCP server (test/helpers/mcp-stdio-server.mjs): server connects, tools=9 mcp=1 in the actual request, built-in read/write answered by our boundary (exit 1 on refusal, server untouched), a declined server call never reaches the server (exit 1), and the same call approved by a scripted y really runs on it (exit 0).
  • test/agent-mcp.test.mjs (+4) — request shape (tools absent when empty), name-based routing, and the missing-handler error.

Verified the new tests fail against the current code: tools=0 mcp=1 and the built-in read/write misroute (3 of 5 subprocess tests fail on main).

npm run build   ✓
npm test        ✓ 573 pass, 0 fail
npm run smoke   ✓ SMOKE_OK

Scope: no new surface — this restores the built-ins the prompt already promised alongside MCP, on the existing safety boundary.

With mcp.json present, processLine sent every line to completeWithMcp, which
completed with { mcp } alone: the nine built-in actions the system prompt
advertises were never offered to the model, so a wallet request was a no-op and
a scripted run still exited 0. A built-in call the model did make was answered
by the server's catalog instead of our safety boundary.

completeWithMcp now takes the agent's own tool definitions alongside mcp (an
empty list keeps the request byte-identical to the MCP-only shape) and routes
by name: built-in names go to invokeNativeToolCall, server names to the
existing invokeToolCall gate. Both turn drivers share dispatchNativeCall, so a
write and an account switch reach the same handleAction boundary — resolve,
policy, preview, mainnet ack, y/N — that a slash command uses, while reads go
through dispatchToolCall and are printed once. Refusals, tool errors and a hit
round limit fold into hadFailure, so a scripted run still exits 1. The MCP
confirmation gate is untouched: a server tool still asks, and a declined call
never reaches the server.

The turn body lives in src/nativeToolLoop.mjs beside the native loop and is
reachable from tests; cli.mjs only passes in the handles it needs, and exports
invokeMcpToolCall next to handleAction so the gate is testable.

Tested: npm run build, npm test (573 pass), npm run smoke (SMOKE_OK).

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

The combined native/MCP routing and policy coverage look good. One error path still needs a fix in runMcpTurn (src/nativeToolLoop.mjs:301-313). If dispatchNativeCall throws, boundary stays "handleAction", so the catch creates an error result but the following print branch skips it.

I reproduced this with get_balance throwing Error("RPC unavailable"): the model receives Error: RPC unavailable, hadFailure is true, and the operator output is only newlines. A scripted run can therefore fail without showing the cause; an interactive run depends on the model choosing to repeat it.

Please print caught exceptions directly and keep the no-duplicate behavior for results already printed by handleAction. Add a regression for a throwing read (and a throwing write boundary) asserting the error appears once, reaches the model, and still fails scripted runs. The existing 573 tests pass locally.

runMcpTurn's catch built an `Error: …` result but `boundary` still read
"handleAction", so the read-only print branch skipped it: get_balance failing
with "RPC unavailable" left the operator watching blank lines while a scripted
run failed, and an interactive one depended on the model choosing to repeat
the cause.

Print the caught exception in the catch — nobody else has printed it, so there
is no duplicate — and keep the failure flag. handleAction's own writes still
print themselves exactly once, reads still print through dispatchToolCall, and
the result still reaches the model either way.

Tested: npm run build, npm test (576 pass), npm run smoke (SMOKE_OK). The two
new regressions fail on the reviewed commit and pass with this one.
@Yash-arch-ui

Copy link
Copy Markdown
Collaborator Author

Thanks — fixed in 3d9eabc.

The bug: in runMcpTurn's catch, boundary was still "handleAction", so the read-only print branch skipped the Error: … result it had just built. The read boundary that would have printed it is the one that threw, so nothing did: blank lines for a scripted run, and an interactive run relying on the model to repeat the cause.

The fix (src/nativeToolLoop.mjs): the catch prints the exception itself, in red, and returns — nobody else has printed it, so there is no duplicate. Everything else is unchanged: a write result from handleAction still prints only from handleAction, a read still prints only from dispatchToolCall, and the tool result reaches the model either way. Refusal detection, hadFailure and the round/error paths are untouched.

Regressions added (test/mcp-native-dispatch.test.mjs, counting both print channels):

  • throwing read (get_balance → RPC unavailable): printed exactly once, Error: RPC unavailable is the tool result in history, handleAction never called, hadFailure → exit 1;
  • throwing write boundary (send_mon → signer unavailable): same four assertions, and dispatchToolCall never called;
  • a successful read is still printed exactly once, so the no-duplicate contract is pinned too.

Both throwing tests fail on the reviewed commit (12 pass / 2 fail) and pass with the fix.

npm run build   ✓
npm test        ✓ 576 pass, 0 fail
npm run smoke   ✓ SMOKE_OK

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

lgtm, thanks for fixing the missing error output

@portdeveloper
portdeveloper merged commit 9871f73 into portdeveloper:main Oct 5, 2026
3 checks 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.

Connecting an MCP server silently disables every built-in wallet tool

2 participants