feat(mcp): process-wide tool catalogue discovery

McpProcess starts a server through the SDK stdio client under the startup
timeout and keeps a stderr tail for its errors. McpCatalogCache runs one shared
discovery per server config in a temp dir, with the environment from the process
spawn policy. It keeps successes for the process lifetime and failures for 60 s.

Deviation from the plan: the discovery process is disposed and its temp dir
deleted in the background, after the catalogue is returned. SDK 2.2 waits the
whole ShutdownTimeout (5 s) with stdin still open before it kills the process,
so an awaited dispose held every first activation for 5 s. A new test
(Discovery_returns_without_waiting_for_the_server_shutdown) covers this. Spec
section 3 gets the new GetAsync signature, the detached discovery and the
background cleanup. Section 5 now describes the SDK's shutdown order correctly.
This commit is contained in:
EugeneTes
2026-09-26 20:31:43 +02:00
parent 52f9d88575
commit ed026ac6c8
7 changed files with 456 additions and 4 deletions

View File

@@ -194,12 +194,16 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
### 3. Catalogue — `McpCatalogCache`
`Task<McpCatalog> GetAsync(McpServerConfig, ILogger, CancellationToken)`, process-wide (`Shared`):
`Task<McpCatalog> GetAsync(name, McpServerConfig, IProcessSpawnPolicy, envLookup, ILogger, CancellationToken)` (internal;
the extension passes `api.Processes` and the host environment lookup), process-wide (`Shared`):
- Concurrent activations for the same key share one in-flight task, so ten chats starting at once make one spawn.
The shared discovery runs detached from any caller's token; each caller waits with its own.
- Discovery spawns the server in a fresh temp dir, with its environment from `api.Processes.Prepare` (activation has no
session, so only host hooks run and `TZ` isn't set), runs `initialize` and a paginated `tools/list` under
`startupTimeout`, captures `serverInfo`, then disposes the client and deletes the dir.
`startupTimeout`, captures `serverInfo`, then disposes the client and deletes the dir in the background. The
catalogue is returned without waiting for that cleanup, because SDK 2.2's dispose waits the whole `ShutdownTimeout`
before it kills the process (see §5).
- Success is cached for the process lifetime. Failure is cached for 60 s, then retried. The failure message
includes the last 20 stderr lines, as in the TS bridge, because a server that dies during the handshake explains
why only there.
@@ -241,8 +245,9 @@ One per server per extension instance (that is, per session):
`"<name> MCP server exited (<code>): <stderr tail>. It restarts on the next call; browser state was lost."`. The
next call respawns it. There's no automatic retry of the failed call, because it may have had side effects.
- The idle timer (reset per call) disposes the client after `idleTimeout` with no calls in flight.
- `DisposeAsync` is idempotent: dispose the client (the SDK closes stdin, waits `ShutdownTimeout`, then kills), then
kill the process tree if it's still alive. The extension calls it on every `SessionShutdownEvent` (quit, new,
- `DisposeAsync` is idempotent: dispose the client, then kill the process tree if it's still alive. SDK 2.2 waits up to
`ShutdownTimeout` for the process to exit with stdin still open, then kills its tree, so disposing a live server
always takes about `ShutdownTimeout`. The extension calls it on every `SessionShutdownEvent` (quit, new,
resume). A new session in the same chat starts from a fresh process, and the persistent profile keeps the logins.
- If the live server's `tools/list` differs from the cached catalogue (a package upgrade in place), a warning is
logged. Calls go by name either way.