Skip to content

feat(harness): run MCP servers per session and declare their tools - #80

Draft
sanchitrk wants to merge 3 commits into
claude/sleepy-gates-ce83d0from
claude/cod-114-stacked-pr-92a28d
Draft

sanchitrk wants to merge 3 commits into
claude/sleepy-gates-ce83d0from
claude/cod-114-stacked-pr-92a28d

Conversation

@sanchitrk

@sanchitrk sanchitrk commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacked on #79 (MCP client). Turns the client into something a session uses: servers from mcp.json, kept connected across a session's exchanges, with their tools declared to the model. Behaviour follows Pi's MCP integration; the runtime is built on Effect scopes.

Config: mcp.json

  • Read next to settings.jsonc: the user layer (--user-config-dir or --home), then the project's .codework/mcp.json from the session's hostDir.
  • A project entry replaces a user entry of the same name, or overrides only enabled / exposure / toolExposure of it without restating it, so credentials stay in the user file.
  • A broken file or entry is reported and skipped; other servers still connect.
  • env and headers values resolve ${VAR} / $VAR and !command when the server connects.

Plugins: a ctx.plugin.mcp bucket

  • A new mcp plugin domain runs first: { mcp: 1, tool: 2, prompt: 3 }.
  • The bucket is seeded from mcp.json; mcp plugins can list / get / set / update / remove servers, checked with the same rules as the file.
  • It is settled before the tool domain runs. MCP tools are then in the tool bucket, so tool plugins can patch, override or hook them, and prompt plugins index them. Later domains can read the bucket but not write it.
  • Only configuration lives in the bucket; connection state never does.

Runtime: src/mcp/mcp.ts

  • Each session's servers connect in the background. The first exchange waits up to 10 s, and only for servers with direct tools.
  • Each connection lives in its own child scope; lifecycle operations are serialized per server, and startups to one URL are serialized.
  • Tool definitions are frozen per exchange; a call uses the server's current connection when it runs:
    • A connection that drops fails the calls running on it (reason and stderr tail); the next call reconnects first, even within the same exchange.
    • An expired HTTP session is replaced, and calls that hit it run once more on the new one; the old connection closes when its last call finishes.
    • A tool call is never retried otherwise.
    • A withdrawn tool says it is no longer available; a changed tool list takes effect at the next exchange.
  • A config change restarts only servers whose connection settings changed; exposure changes apply without reconnecting.

Tools

  • Named mcp__<server>__<tool> (non-[A-Za-z0-9_] → _, max 64), with a hash suffix on collisions or overflow. Names stay with their tool for the session.
  • Exposure is direct or hidden (per server, or per tool with patterns).
  • Tool.fromJsonSchema (in @codeworksh/plugin) sends the server's JSON Schema to the provider unchanged and validates arguments against its Effect translation. A schema the translation cannot express validates nothing. The server receives the arguments as the model wrote them.
  • isError results and failed calls reach the model as error results.

Other changes

  • JSONC parsing shared by settings and mcp.json (util/jsonc.ts).
  • stdio servers start in their configured cwd, resolved against the session's base directory, with a leading ~/ expanded in command, args and cwd.
  • The client's Connection gains dropped(), so a call notices a drop before the onClose handler has run.

Not in this PR (follow-ups)

  • Resources (resource tools) and truncation / spill-to-file of large and binary results.
  • space / app scopes, idle timeouts and leases. Until then, a session's servers live until the process exits.
  • OAuth sign-in; deferred / codemode exposure.

Review fixes (Codex peer review)

Rebased onto #79's latest head, so it includes onLog / McpLog, condensed validation errors and the cleanup fixes.

  • Idle exit deadlock. A server exiting with no call running retired its connection from inside the connection's own scope, which waited on itself and left the server locked for good. Connection callbacks now run in the service's scope.
  • Connect attempts run on their own. Callers share one attempt, and closing a server interrupts it instead of waiting behind it.
  • Restarted servers keep their tools. An exchange waits up to 10 s for every server it starts (new or restarted), not only on the first exchange.
  • Frozen configs. Server configs in ctx.plugin.mcp are copied and deep-frozen, so a plugin can't change a settled server.
  • Names. A hashed tool name grows until it's free, so a tool named like the hash can't replace another.
  • Logging. Server log messages go to <home>/mcp.log.
  • New test. mcp.session.test.ts gains a lifecycle test, with artifact mcp.session.lifecycle.json. It covers:
    • an idle exit, then a reconnect (it hangs without the deadlock fix)
    • an exposure-only change, which keeps the same process
    • an env change, which starts a new process
    • an HTTP session expiring once (retried on a new session) and twice in a row (the call fails)

Test plan

  • test/mcp.session.test.ts: the full harness with a scripted model and a real stdio fixture server, two exchanges in one session. It covers:
    • Config: user and project layers, a project override, a name clash, an invalid entry, an override with no base.
    • The plugin bucket: an mcp-domain edit, and a tool-domain write being refused.
    • Tools: the JSON Schema passed through, a hidden tool left out, a sanitized name, structured content, an isError result, arguments rejected by validation.
    • A crash mid-exchange, then the next call in the same exchange reconnecting to a new process.
    • Ends with __artifacts__/mcp.session.json, stable across runs.
  • Full harness suite: 514 passed, 34 skipped (live/remote)
  • vp check (the only errors are in sites/www, which doesn't generate its Astro types in this checkout) and vp run typecheck

🤖 Generated with Claude Code

sanchitrk and others added 2 commits October 15, 2026 00:58
Reads mcp.json from the user and project layers, keeps each session's
servers connected across exchanges, and declares their tools to the model.

- mcp.json: project entries replace user ones or override only enabled,
  exposure and toolExposure; broken files and entries are reported and
  skipped. env and headers resolve ${VAR} and !command values.
- Plugins get a ctx.plugin.mcp bucket in a new mcp domain that runs first;
  it is settled before the tool domain, so tool plugins see MCP tools.
- Tool definitions are frozen per exchange; each call uses the server's
  current connection. A dropped connection reconnects on the next call, an
  expired HTTP session is replaced and the call retried once, tool calls
  are never retried otherwise.
- Tools are named mcp__<server>__<tool> with a hash suffix on collisions;
  exposure is direct or hidden.
- Tool.fromJsonSchema sends a server's JSON Schema to the provider as
  written and validates arguments against its Effect translation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server's own relative cwd now resolves against the session's base
directory, and a leading ~/ is expanded in command, args and cwd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sanchitrk
sanchitrk force-pushed the claude/cod-114-stacked-pr-92a28d branch from f3014f3 to b02d026 Compare October 14, 2026 19:30
…tled

- Connection callbacks run in the service's scope. A server exiting while
  idle used to retire its connection from inside the connection's own
  scope, which waited on itself and left the server locked for good.
- Connect attempts run on their own and are shared by callers; closing a
  server interrupts one in progress instead of waiting behind it.
- An exchange waits up to 10 s for every server it starts, not only on
  the first exchange, so a restarted server keeps its tools declared.
- Server configs in ctx.plugin.mcp are copied and deep-frozen, so a
  plugin cannot change a settled server afterwards.
- A hashed tool name grows until it is free, so a server tool named like
  the hash cannot replace another tool.
- Server log messages go to <home>/mcp.log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant