Skip to content

feat(harness): share MCP servers by scope and close idle ones - #83

Merged
sanchitrk merged 43 commits into
canaryfrom
claude/mcp-scopes
Oct 30, 2026
Merged

sanchitrk merged 43 commits into
canaryfrom
claude/mcp-scopes

Conversation

@sanchitrk

@sanchitrk sanchitrk commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacked on #81. Decides who shares a running MCP server and how long it lives. Until now every session ran its own servers until the process exited.

Scopes (scope in mcp.json, default session)

Scope Shared by stdio cwd / roots on a local sandbox
session one session the session's directory
space sessions in one space (sandbox + folder) the space's folder
app every session where the app started, no roots
  • When the sandbox isn't local, session and space servers start in hostDir, else hostCwd, and get no roots. MCP stays host-only.
  • An instance is keyed by its scope plus everything its connection is made from (config minus exposure, cwd, roots). Sessions that resolve different settings for a server, for example through different project files, never share it.
  • Exposure stays each session's own. Two sessions can share an instance and declare different tools.

Lifetime

  • Each drain leases its session's instances for as long as it runs (runner/execute.ts, next to the mount).
  • An instance no session uses closes at once, for example when a server is removed or its settings change.
  • An instance no drain holds closes after an idle timeout: 10 minutes, or the Harness.layer option mcpIdleTimeout. The next exchange that needs it starts it again.
  • Space changes need no hooks. After rehome or absorb, a session's next exchange resolves a different key and drops the old instance; an archived env's instances idle out.

Also

  • A session's servers keep their config order across exchanges, so a restarted server's tools no longer move to the end of the list. That's more stable for prompt caching.
  • Docs: "Sharing a Server" and idle close on the MCP page.

Review fixes (2026-10-15)

  • Name in the key. An instance key includes the server's name. Two entries with the same settings are two instances, and renaming a server can't close the instance its new name uses.
  • Exact lease counts. Leases count each instance once per session, so an alias can't drive a count negative and keep a server from idling out.
  • Atomic bookkeeping. Reconcile updates every membership and lease count in one step and closes unused instances on their own, so an interrupted exchange can't leave a shared instance counting a session it no longer has.
  • Idle timer from the start. An instance created with no drain holding it still gets an idle timer.
  • Canonical keys. Keys are canonical JSON, so the same env / headers in another order share an instance.
  • Dismissed: a lease arriving mid-reconcile. The coordinator runs one drain per session, and reconcile runs inside it. The atomic bookkeeping removes the window anyway.
  • Test: the scope test adds twin, a server with mine's exact settings. It runs as its own process, and everything still idles out.

Tagged errors

Every MCP failure is now a Schema.TaggedError (UnavailableError, ToolCallError, ResourceError, ValueError, OutputError; connect attempts use ConnectError). The tool adapter turns error.message into model text only at the edge. The model-facing text is unchanged, and no artifact moved.

Codemode and deferred exposure

mcp.json accepts direct, hidden, codemode, deferred, and codemode-deferred as an alias, so entries copied from other agents stay valid.

  • Until codemode and tool_search exist, codemode / deferred tools aren't declared, and the session is warned once.
  • Resource tools are declared when a server with resources is direct, and reach every server with resources that isn't hidden.
  • The default stays direct for now, and flips to codemode with the codemode work.

CLI end-to-end

packages/codework/test/cli.mcp.test.ts spawns the real codework run with mcp.json, a fake OpenAI-compatible provider and a hand-written stdio MCP server. It checks that:

  • the tool is declared with the server's schema
  • the call's result reaches the model
  • the server ran in the session's directory
  • the server is gone after the CLI exits

Artifact: cli.mcp.json.

Review fixes (2026-10-24)

Test plan

  • test/mcp.session.test.ts gets a new scope test, through the full harness with real stdio servers. Sessions A and B share a space and C is in another, each calling a session, a space and an app server:
    • session: three processes
    • space: A and B share one, C has its own
    • app: one process for all three
    • After the drains end, every instance idles out and its process is gone. A later run starts fresh instances.
    • Artifact: mcp.session.scopes.json, stable across runs.
  • Lifecycle artifact: only the more stable tool order changed.
  • Full harness suite: 525 passed, 34 skipped (live/remote)
  • vp check and vp run typecheck

🤖 Generated with Claude Code

@sanchitrk

Copy link
Copy Markdown
Contributor Author

Review (2026-10-24)

No blockers. Checked and correct:

  • Lease and membership bookkeeping under concurrent drains.
  • Idle close vs. a call that is starting: the lease is taken in execute.ts before runner.run.
  • inflight gating on retire and release.
  • rehome / absorb key changes.
  • Keys hold raw templates, not resolved secrets.

should-fix: a shared instance whose first connect failed is never retried

  • Where: mcp.ts L565–588. Same root cause as the feat(harness): run MCP servers per thread and declare their tools #80 finding, and worse here.
  • Scenario:
    1. An app-scoped server is unreachable when the first session starts, for example with the VPN off.
    2. Every later session, in any project, joins that failed instance with no tools.
    3. This lasts until no session has drained for 10 minutes.
  • Fix: in reconcile, restart existing instances that are failed / disconnected with no live, no attempt and no tools, and push them to started.

nit: the sessions map is never pruned

  • Where: mcp.ts L506–521.
  • What happens: a finished or deleted session keeps its record, and its id stays in server.users. A long-running server grows by one entry per session. A shared instance then never takes the "no users, close now" path; it only idles out.
  • Fix: drop the session when it is deleted or archived, or when its last lease releases and it has no servers.

nit: simplify the lease counting

  • What to derive: "held" can come from users' lease counts ([...server.users].some(id => (sessions.get(id)?.leases ?? 0) > 0)). Re-arm in used(session). That replaces the per-instance mirrored leases, which already had one fixed bug.
  • Timer: keep one timer fiber per instance and interrupt it on re-arm, instead of generation. Every release currently forks a fresh 10-minute sleeper.

nit: the scope test is timing-sensitive

  • Where: mcp.session.test.ts L418 uses mcpIdleTimeout: "1500 millis".
  • Why: the gaps between the sequential a/b/c runs have no lease. On a slow runner, setup between runs can take over 1.5 s, so pids change and the artifact drifts.
  • Fix: hold a lease across the three runs, or run them concurrently, and use the short timeout only for the idle phase.

DevX

  • Idle close for session scope: it also closes a session's own servers after 10 minutes without a drain. That throws away state for stateful servers (browser, DB session) while the user is thinking.
    • The cheaper alternative: exempt session scope, since those instances already close when the session stops naming them.
    • Worth deciding before this is user-facing.
  • Docs: say that app servers get no roots, so a filesystem server configured with "." isn't a surprise.

sanchitrk and others added 6 commits October 24, 2026 19:09
Connect to one MCP server over stdio or Streamable HTTP through the
official SDK. Stdio servers run on the host in their own process group
and are stopped with the group; HTTP connects retry transient failures.
List pages and tool results are validated by the SDK.

The process-group stop sequence moves out of the host shell into a
shared Process.terminate helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connections pass the server's notifications/message entries to an
onLog handler, and McpLog appends them to a file with one line per
entry, rotating it past 5 MB.

A result that fails the SDK's schema is reported on one line as
`Invalid result for <method>: <path>: <message>` instead of the
validator's multi-line issue list, which is still logged at debug level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name the MCP entry points with Effect.fn and give each request its own
span. A server without resource templates is now recovered from inside
the Effect rather than on the SDK promise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Count stderr as an open pipe, so a descendant holding only stderr is
  still killed with the server's process group.
- Bound the whole connect attempt by the startup timeout; the SDK only
  times the initialize request, not the initialized notification.
- End the HTTP session a failed connect attempt was given, through a
  fresh transport, since the SDK aborts the failed transport's signal.

Tests now cover these, retries of reads but not tool calls, session
termination and 400 classification, and register their own teardown
for spawned processes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The process spawner kills the group when the connection's scope closes,
which hid a missing kill. Assert instead that a grandchild holding only
stderr is gone once the connection reports the server's exit.

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

- Retry connects only on HTTP 408/429/5xx and fetch failures with a system code;
  any other TypeError, such as an invalid header value, fails once.
- Retry list and read requests once, 250 ms later, after a transient HTTP error.
- Bound startup by the request timeout unless set on its own.
- Ask for progress only on requests given a progress handler.
- SIGTERM a stdio server's process group even after the server exits on its own,
  and before SIGKILL when the leader is gone but a pipe is still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The log handler is now a connect option instead of a setter called after the first tool
listing, so messages a server sends while starting, or before failing to, are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sanchitrk and others added 16 commits October 24, 2026 22:22
A tools/list_changed sent while the first listing is answered used to reach no handler,
leaving the old list in place; it is now reported once the handler is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…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>
- Refuse env and header values with a line break or NUL without quoting them.
- Run a !command with stdin closed, and reuse its output for the life of the process.
- Report auth/oauth entries as unsupported instead of ignoring the keys.
- Drop the nulls strict sampling writes for omitted optional arguments before the call.
- Validate JSON Schema patterns instead of skipping the whole schema.
- Refresh a changed tool list without holding the server lock.
- Retry a server whose connect failed at the next exchange, in the background.
- Stop removed or changed servers without making the prompt wait.
- Keep hashed tool names within 64 characters.

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

- Start a connect attempt after it is recorded, so one that fails at once (an unset
  variable, say) no longer blocks every later retry.
- Close server connections in parallel on shutdown, after interrupting connect attempts.
- Keep only the latest tool listing when list_changed notifications overlap.
- Write server log messages from the handshake on.
- Kill a !command that ignores SIGTERM, and share one run between concurrent connects.
- Allow line breaks in env values (a PEM key); headers still refuse them.
- Reject arguments a closed JSON Schema object does not declare, instead of passing them on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Write server log messages on the service's fibers, so a failed handshake closing the
  connection no longer drops the messages that explain it.
- Forget a failed !command as soon as it fails, so the next connect runs it again even
  when every caller waiting on it gave up.

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

- list_mcp_resources, list_mcp_resource_templates and read_mcp_resource
  reach every enabled server with resources, through the same request
  path as tool calls (reconnect, expired-session recovery, sign-in).
  Listings page per server or cover every page; MCP App resources are
  left out.
- Results keep the start and end of text over 20 KB and save the full
  text; binary resources are saved, text-typed blobs shown as text, and
  resource links name read_mcp_resource.
- Saved files go to the host temp directory (mode 0600), since MCP runs
  on the host whatever sandbox a session uses.
- Docs: an MCP page under Customize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Listing every server's resources or templates uses the SDK's own
  pagination and its limits, so a server repeating a cursor cannot keep
  a listing running.
- A cursor goes back to the server exactly as it was given; only server
  names and URIs are trimmed.
- Tests remove the files they save, and read saved paths between the
  message's own delimiters.
- Docs: which servers a message waits for, exposure changes keep a
  server running, and where server logs go.

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

- A blank cursor lists the first page; any other cursor goes back exactly as given.
- Resource tools wait for servers still connecting, so none is left out of a listing.
- Resource failures carry the server's own message; listings name the server beside it.
- Docs: where stdio servers start off a local sandbox, $NAME values, command caching,
  sign-in, and retries after a failed connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tool and resource images are converted to a format providers take inline and resized to
their limits, as the read tool does; one that cannot be becomes a note instead of an image
that would fail the next request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A server's scope decides who shares a running instance: session (the
  default), space (sessions in one space) or app (every session). An
  instance is keyed by its scope and everything its connection is made
  from, so sessions resolving different settings never share one.
- Exposure stays each session's own; sessions sharing an instance can
  declare different tools.
- Each drain leases its session's instances. An instance no session uses
  closes at once; one no drain holds closes after an idle timeout
  (10 minutes, Harness option mcpIdleTimeout).
- space servers start in the space's folder and are sent it as root on a
  local sandbox; app servers start where the app started, with no roots.
- A session's servers keep their config order across exchanges, so a
  restarted server no longer moves its tools to the end.
- Docs: sharing a server, and idle close.

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

- An instance key includes the server's name, so two entries with the same
  settings are two instances and a renamed server never closes the
  instance its new name uses.
- Leases count each instance once per session, so an alias can no longer
  drive a count negative and keep a server from idling out.
- Reconcile updates every membership and lease count in one step and
  closes unused instances on their own, so an interrupted exchange cannot
  leave a shared instance holding a session it no longer has.
- An instance that starts with no drain holding it still gets an idle
  timer.
- Keys are canonical JSON, so the same env or headers in another order
  share an instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every MCP failure is a Schema.TaggedError with the message the model
reads, turned into a tool result only at the edge:

- UnavailableError: disabled, shut down, not connected, needs sign-in,
  or a tool no longer offered.
- ToolCallError: a tool call or resource request failed on the server.
- ResourceError: unusable resource-tool arguments, or a server without
  resources.
- ValueError: an env or headers value that does not resolve.
- OutputError: output that could not be saved.

Connect attempts fail with ConnectError. The text the model sees is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codework run reads mcp.json next to the user settings, starts a stdio MCP
server written against the wire protocol, declares its tool to a fake
OpenAI-compatible provider with the server's schema, returns the call's
result to the model, and stops the server when the CLI exits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mcp.json accepts every exposure Pi does: direct, hidden, codemode and
deferred, with codemode-deferred read as codemode, so entries copied
from Pi stay valid. Until codemode and tool_search exist, codemode and
deferred tools are not declared and a session is warned once that they
cannot be reached. Resource tools are declared when a server with
resources is direct, and reach every server with resources that is not
hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sanchitrk and others added 3 commits October 24, 2026 22:23
…ks reachable

- Restart an instance whose connect failed at the next exchange, so shared space/app
  instances recover for every session using them.
- Carry earlier fixes onto the scoped service: tool-list refresh without the server
  lock, plain resource failures, and resource tools waiting for connecting servers.
- Point resource links to read_mcp_resource only when that tool is declared.
- Drop the record of a session with no MCP servers once its drain ends.
- Hold a lease across the scope test's runs so a slow runner cannot change its pids.
- Docs: app servers get no roots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scoped service gets the fixes from the session runtime: attempts recorded before they
start, connections closed in parallel after attempts are interrupted, the latest tool
listing kept, server log messages from the handshake on, and header/env value checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Server log messages are written on the service's fibers here too, so a failed handshake
no longer drops the messages that explain it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sanchitrk and others added 16 commits October 30, 2026 12:41
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each thread now owns its MCP servers: reconcile, status and the new
connect and disconnect take the thread, so concurrent threads of one
session, or threads at different placements, no longer overwrite each
other. A thread's record is dropped once it has no servers left.

A failed server is no longer retried by every turn; it recovers through
a config change or an explicit connect. Disconnect closes a server and
keeps it closed until connect or a config change. A dropped connection
still reconnects on the next call.

Every tool a server offers is registered with its exposure, so deferred
tools are found by toolsearch, codemode and hidden ones are registered
but not offered. mcp.json accepts codemode and deferred exposure, with
codemode-deferred read as codemode. A loaded deferred tool makes the
turn wait for its server like a direct one.

State changes are published as the ephemeral mcp.server.status event to
the owning thread, and the needs-auth state is now authenticate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connect and disconnect run one at a time per server and return the
status read under that lock; a server a turn replaced meanwhile is
reported unknown. Stopping reads and interrupts the connect attempt
under the server lock, and an attempt that finishes after its server
was closed or disconnected closes its connection instead of installing
it. An explicit connect always publishes a fresh connecting status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resource tools reach the thread's servers, skip disconnected ones, and
stay declared to the model. Sign-in failures on resource requests set
the authenticate state through the status event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shared instances now serve threads: a session-scope instance is keyed
by session plus the thread's cwd and roots, so threads at the same
place share it and a thread placed elsewhere gets its own. Instance
users and leases count threads, and lease takes the thread. Status
events go to every thread using an instance; a thread that starts
using a running instance gets its current status, and a disabled entry
reports disabled to its thread. Connect and disconnect act on the
instance behind the thread's server.

Reconcile no longer restarts failed shared instances. Every tool is
registered with its exposure, the unreachable warning covers codemode
only, and UnknownServerError joins the tagged MCP errors.

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

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

An instance that idles out publishes disconnected to the threads still
using it. A thread record with reported config errors is kept, so they
are not logged again every turn. A new instance is started on the flag
set when it was created, and a thread joining a running instance is
told its status only if nothing newer was published since it joined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…upted attempts by state

Deleting a session now forgets its threads' MCP records and closes their
servers. A caller waiting on a connect attempt that was interrupted is told
whether the server was shut down, disconnected or is reconnecting, and
connect-time close reasons read like the ones after connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connect on an instance that idled out ran into a shut-down server and
reported it disconnected with no error. It now says the instance closed
while idle and that the thread's next message starts it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sanchitrk
sanchitrk changed the base branch from claude/mcp-resources to canary October 30, 2026 14:09
@sanchitrk
sanchitrk marked this pull request as ready for review October 30, 2026 14:11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sanchitrk
sanchitrk merged commit 67d99c3 into canary Oct 30, 2026
2 checks passed
@sanchitrk
sanchitrk deleted the claude/mcp-scopes branch October 30, 2026 14:12
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