Skip to content

feat(harness): add MCP resource tools and keep oversized output on the host - #81

Merged
sanchitrk merged 27 commits into
canaryfrom
claude/mcp-resources
Oct 30, 2026
Merged

sanchitrk merged 27 commits into
canaryfrom
claude/mcp-resources

Conversation

@sanchitrk

@sanchitrk sanchitrk commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacked on #80. Adds MCP resources, and handling for results the model isn't shown in full. Everything stays on the host: MCP runs in the harness process and never touches a session's sandbox, local or remote.

Resource tools (src/mcp/resources.ts)

  • list_mcp_resources, list_mcp_resource_templates and read_mcp_resource, named and shaped like the tools other agents use, so models trained on them use them unchanged.
  • They're registered when any enabled, non-hidden server offers resources, and reach every such server when they run.
  • With a server, one page is listed and cursor continues it. Without one, every page of every server is listed, and servers that fail go into errors.
  • MCP App resources (ui://, profile=mcp-app) and icons are left out of listings.
  • Reads become text and images; several contents are labelled with their URIs.

Result content (src/mcp/content.ts)

  • Text over 20 KB keeps its start and end, cut in the middle, and the full text is saved to a file whose path the result names.
  • Binary resources are saved; text-typed blobs (text/*, JSON, XML) are shown as text.
  • Resource links name read_mcp_resource when the server's resources can be read.

Saved files (src/mcp/output.ts)

  • They go to the host temp directory as codework-mcp-*, mode 0600, created exclusively, never into a sandbox.

One request path (src/mcp/mcp.ts)

  • Tool calls and resource requests share it: the current connection is looked up when the request runs, a dropped one reconnects, an expired HTTP session gets one more attempt on a new session, and a sign-in failure is reported. Tool calls are still never retried otherwise.

Docs

  • A new page, customize/mcp: where servers run (always on your machine), mcp.json, project overrides, exposure, resources, connections.

Review fixes (2026-10-15)

  • Strict paging. Listing every server's resources or templates uses the SDK's own pagination and limits, as tools() does. A server repeating a cursor can't keep a listing running.
  • Opaque cursors. A cursor goes back exactly as the server gave it; only server names and URIs are trimmed. The fixture's cursor is now " page-2 " to prove it.
  • Test cleanup. Tests remove the files they save, and read saved paths between the message's own delimiters, so a temp directory with spaces still parses.
  • Docs. Which servers a message waits for, that exposure changes keep a server running, and where server logs go.

Review fixes (2026-10-24)

  • Blank cursor: cursor: "" means no cursor; any other cursor still goes back exactly as given.
  • Connecting servers: resource tools wait for servers still connecting, so none is silently left out of a listing.
  • Errors: resource failures carry the server's own message; listings already 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.
  • Tests: the third exchange lists with a blank cursor.
  • Images: tool and resource images go through the shared inline pipeline (format conversion and resizing, as the read tool does); one that can't be converted becomes a note.

Test plan

  • test/mcp.session.test.ts gains a third exchange, through the full harness against a real stdio server. It covers:
    • the server's configured cwd
    • a resource link
    • a 30 KB result cut below 21 KB, with the saved file holding all of it
    • listing every page and paging one server by cursor, with the ui:// resource left out
    • templates
    • a text read and a binary read, with the binary saved as a 0600 host file holding the right bytes
    • an unknown server
  • test/mcp.client.test.ts: artifact updated for saved binaries and resource links
  • Full harness suite: 514 passed, 34 skipped (live/remote)
  • vp check and vp run typecheck

🤖 Generated with Claude Code

@sanchitrk
sanchitrk force-pushed the claude/cod-114-stacked-pr-92a28d branch from f3014f3 to b02d026 Compare October 14, 2026 19:30
@sanchitrk
sanchitrk force-pushed the claude/mcp-resources branch 2 times, most recently from 60888b5 to 29d2570 Compare October 15, 2026 03:49
@sanchitrk sanchitrk mentioned this pull request Oct 24, 2026
4 tasks done
@sanchitrk

Copy link
Copy Markdown
Contributor Author

Review (2026-10-24)

Resource tools, truncation, saved files and the ui:// filter all check out.

should-fix: a blank cursor should mean no cursor

  • Where: resources.ts (L121 in this head) keeps cursor: "".
  • Why it matters: models often fill optional strings with "".
    • With no server, list_mcp_resources({ cursor: "" }) fails with "cursor can only be used when a server is specified".
    • With a server, "" is sent to it as a cursor, and many servers reject it.
  • Fix: keep non-blank cursors byte-exact, but treat blank ones as absent: value.trim() === "" ? undefined : value.

Confirmed, no action:

  • The saved-output path isn't readable from a remote sandbox. That was decided (host-only, no notice to the model), so it isn't raised again here.
  • Not listing resources at connect, only checking the resources capability, matches current reference behaviour.

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>
@sanchitrk
sanchitrk force-pushed the claude/cod-114-stacked-pr-92a28d branch from 08e23a4 to 403cdd6 Compare October 24, 2026 14:19
@sanchitrk
sanchitrk force-pushed the claude/mcp-resources branch from 29d2570 to 386dd54 Compare October 24, 2026 14:19
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
sanchitrk force-pushed the claude/cod-114-stacked-pr-92a28d branch from 403cdd6 to 94bb0b9 Compare October 24, 2026 15:41
@sanchitrk
sanchitrk force-pushed the claude/mcp-resources branch from 386dd54 to dfa8e70 Compare October 24, 2026 15:41
sanchitrk and others added 11 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>
@sanchitrk
sanchitrk force-pushed the claude/cod-114-stacked-pr-92a28d branch from 94bb0b9 to 4cd6ee1 Compare October 24, 2026 16:55
@sanchitrk
sanchitrk force-pushed the claude/mcp-resources branch from dfa8e70 to f815324 Compare October 24, 2026 16:56
sanchitrk and others added 8 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>
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>
…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>
@sanchitrk
sanchitrk changed the base branch from claude/cod-114-stacked-pr-92a28d 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 7ce5ec3 into canary Oct 30, 2026
2 checks passed
@sanchitrk
sanchitrk deleted the claude/mcp-resources 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