Skip to content

feat: add, remove and list MCP servers from the CLI - #107

Merged
sanchitrk merged 85 commits into
canaryfrom
claude/dazzling-elgamal-c659ae
Oct 30, 2026
Merged

sanchitrk merged 85 commits into
canaryfrom
claude/dazzling-elgamal-c659ae

Conversation

@sanchitrk

Copy link
Copy Markdown
Contributor

Stacked on #106.

What

  • codework mcp add <name> -- <command> [args] or --url <url>, with --env, --cwd, --header, --bearer-token-env-var, --oauth-*, --exposure, --description, and -l/--local for the project's .codework/mcp.json. The entry is validated before it is written.
  • codework mcp remove <name> [-l], which names the other file when the server is defined there.
  • codework mcp list [--json] connects to every enabled server through the real Mcp service and prints its state, tools (with per-tool exposure), resources and errors. Exits 1 on a broken entry or an enabled server that does not connect.

Harness

  • Mcp.Status lists the tools a server offers with their exposure in the session, and whether it offers resources (it was a tool count). The three MCP session artifacts change accordingly.
  • Mcp.settle waits for every server's first connect attempt; Mcp.check connects servers once outside any session and reports them.

Notes

  • Edits keep comments, key order and unrelated entries. The plugin and mcp commands now share one JSONC writer (cli/jsonc.ts); removals skip formatting, which used to reflow the sibling before the removed entry.
  • Docs: a short "Add and Check Servers" section on the MCP page.

Tests

  • New E2E test/mcpcli.test.ts drives the real CLI against a stdio fixture and the OAuth fixture; artifact test/__artifacts__/mcpcli.json.
  • vp check and vp run typecheck pass; harness MCP suites and the plugin command tests pass.
  • Known: cli.mcp.oauth.test.ts "signs in to an OAuth server…" fails on the base branch as well (the revoked-token case answers "Unknown tool"); not addressed here.

🤖 Generated with Claude Code

sanchitrk and others added 30 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>
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>
…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>
Streamable HTTP MCP servers without an Authorization header sign in with
OAuth. The SDK runs discovery, client registration, PKCE, the code
exchange and refresh; the harness keeps the credentials and decides when
each step happens.

- Credentials live in <home>/mcpauth.json (0600), keyed by server name
  and URL. Writes take a file lock; a refresh holds a per-server lock
  across processes, and concurrent 401s in one process share it.
- Connections never open a browser: they refresh, or tell the model to
  run `codework mcp login <name>`. A 403 insufficient_scope is stored so
  the next sign-in asks for the missing scope. A server that needed
  sign-in reconnects once its credential changes.
- `codework mcp login` connects first, then runs the browser flow against
  a loopback callback or a pasted redirect URL; `codework mcp logout`
  removes the entry.
- `auth: { provider }` sends a model provider's login token or API key
  as the bearer, read on every request; user mcp.json only, https or
  loopback only.
- On top of the SDK: http(s)-only URLs, null and "" fields treated as
  absent, no query on well-known metadata URLs, an iss without
  discovered metadata rejected, 15 s per authorization-server request.
- The client registers as CodeWork with the app icon.
- One Effect lock helper on proper-lockfile replaces the plugin store's
  own lock; aikit exports the OAuth result pages for both logins.

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

Mcp.Status now lists the tools a server offers with their exposure in the
session, and whether it offers resources. Mcp.settle waits for every server's
first connect attempt, and Mcp.check connects a set of servers once, apart
from any session, and reports how each one stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codework mcp add writes a stdio or HTTP server into the user mcp.json, or the
project's with -l, and checks the entry first. codework mcp remove takes one
out and points to the other file when the server is defined there. codework
mcp list connects to every enabled server and prints its state, tools and
errors, or JSON with --json, and exits 1 on a broken entry or a server that
does not connect.

Edits keep comments, key order and other entries. The plugin and mcp commands
share one JSONC writer, and a removal no longer reformats the entry before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sanchitrk and others added 26 commits October 30, 2026 17:18
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a new credential

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a provider-keyed server refusing a wrong key reports the provider sign-in hint, and connects with the right key
- the loopback callback ignores stray, unauthenticated and replayed requests, and a denied authorization fails the login with its description
- codework mcp login accepts the redirect URL pasted into a terminal
- tokens are never sent to another URL configured under the same server name

The fixture gains `deny`, `beforeToken` and a record of presented bearer tokens; runCli can run the CLI in a pseudo-terminal.

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

- A changed tool list applies from the next request, a withdrawn tool says so, and only the
  latest of overlapping listings is kept.
- Disconnecting, deleting the session, reconfiguring or removing a server while its first
  connect runs stops the process for good; the startup wait for direct tools is bounded.
- Concurrent calls that meet an expired HTTP session share one new session, and the old one
  stays open for a call still running on it.
- A thread resumed in a new process awaits the server of a tool it loaded.

The shared turn helpers move to test/fixtures/mcp/turns.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…are still the ones presented

A refresh refused with invalid_grant made the SDK invalidate the stored
tokens unconditionally. If another process signed in while the refresh was
pending, its new tokens were deleted. Invalidation now compares the stored
access token with the one this run read or saved, inside the credential
file's lock, and leaves newer credentials alone.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the legacy wire era the SDK passes no request signal to the transport.
A request cancelled while it waited for a token refresh or an authorization
retry was still sent afterwards, so an interrupted tool call ran on the
server. Each request now gets a signal, aborted when its cancellation is
sent.

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

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

The SDK checks a tools/list page as a whole, so one malformed tool failed
every tool of the server. Each entry is now checked on its own with the
SDK's own check for the negotiated protocol version, before the SDK sees
the page; rejected entries are dropped and the rest kept. Nothing is
loosened: a non-object output schema is rejected like any other entry, and
a non-object structuredContent still fails its call.

What was skipped is reported as `skipped` (names, and a count of entries
without a usable name) on the server's Status and its `connected` status
event, on connect and on every list_changed refresh, and each skip is
logged with the SDK's reason to mcp.log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`McpStatus` carries `skipped`, and `codework mcp list` and `reconnect`
print it after the tool count, e.g. `connected, 1 tool — skipped broken,
1 unnamed tool`. The MCP docs say where the reason is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codework mcp login now reads the redirect URL from standard input whether or not it is a terminal. Piped input that ends without a line no longer matters: the loopback callback can still finish the sign-in. A terminal closing still cancels. An unanswered prompt is ended with a newline, so the outcome starts on its own line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The paste case writes the redirect URL to the CLI's stdin instead of running it in a pseudo-terminal, so the Python helper and runCli's tty option go. A new case closes stdin at start and signs in through the callback.

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

A server may keep the access token and rotate only the refresh token, so an
invalidation that compared access tokens alone could still delete newer
credentials another process stored. It now compares the access and refresh
tokens, and the client registration when that is invalidated too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ending refresh is refused

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

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

The default was the server's base URL, which the client opened as a
WebSocket at / while the server answers RPC at /rpc/v1, so commands such as
mcp reconnect and plugin reload failed against a server on the default port.

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

- Reconcile starts the instances it creates within its bookkeeping step, so
  a turn interrupted while it tells a thread about an instance it joined no
  longer leaves new instances connecting forever.
- An instance its last thread leaves is removed and marked closed in the
  same step, by reconcile, session removal and the idle timeout alike; only
  stopping it runs in the background, so no thread can join it meanwhile.
- A call on a replaced connection, or on an instance disconnected or closed
  since, that fails needing sign-in no longer overwrites the instance state.
- Disconnect, and connect up to starting its attempt, run uninterruptibly
  once begun, so a cancelled caller never leaves them half applied.
- A status is published to a thread only if it still uses the instance and
  exists, checked right before each publish.

The raw HTTP test server now mints a session per initialize and lets a
scripted answer wait on a promise.

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

- Each listing asks the server, never the SDK cache, runs alone and returns
  what it skipped with its tools, so overlapping listings no longer mix
  skipped entries and cursors; a listing drops what the previous one kept.
- Pending tools/list request ids are dropped on response, failed send,
  cancellation and transport close.
- An HTTP request keeps its abort signal until its response arrives, it is
  cancelled, its send fails or the transport closes, not only until an SSE
  response opens.

The stale-listing test now holds the first listing until the test releases
it rather than on a timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Count status events from what was recorded, so the deleted-session check
  is no longer vacuous.
- Wait for the fork's deferred server to connect, and run a turn, before
  reading its status and tools.
- Tell expired-session calls apart by the session they carry, hold both
  original calls until they have arrived, and release the old session's call
  once the retry reached the new one.

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-oauth-65afa7 to canary October 30, 2026 14:10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sanchitrk
sanchitrk merged commit dd29ef3 into canary Oct 30, 2026
2 checks passed
@sanchitrk
sanchitrk deleted the claude/dazzling-elgamal-c659ae branch October 30, 2026 14:13
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