feat(mcp): per-session server connection and MCP tools

McpServerConnection starts the session's server lazily through
PrepareProcessAsync, applies the call timeout, reports exits with the
stderr tail and restarts on the next call, stops when idle, and supports
StopAsync (reusable) and idempotent DisposeAsync. McpTool exposes a
catalogue entry as an ITool and maps results with McpResultMapper.

Spec change (§5, Decisions): ModelContextProtocol.Core 2.2.0 does not send
notifications/cancelled when a request's token fires (verified against the
fake server), so calls go out through SendRequestAsync with their own
request id and the connection sends notifications/cancelled itself on abort
and timeout. Also documents exit detection, StopAsync vs DisposeAsync and
JSON-RPC errors as ordinary error results, per the plan.
This commit is contained in:
EugeneTes
2026-09-26 20:44:15 +02:00
parent ed026ac6c8
commit 83ad683bd1
7 changed files with 689 additions and 8 deletions

View File

@@ -3194,6 +3194,8 @@ internal sealed class McpServerConnection : IAsyncDisposable
}
```
(As implemented: SDK 2.2.0 does not send `notifications/cancelled` when the call's token fires, so `CallAsync` sends the request through `SendRequestAsync<CallToolRequestParams, CallToolResult>` with its own id `volcanic-<n>` and sends the notification itself on abort and timeout; the idle stop re-checks the in-flight count under the gate. The spec §5 says so.)
(If `ExitCode` is a non-nullable `int`, drop the `?.` before `ToString`. If `McpProtocolException` lives in another namespace, adjust the using. A JSON-RPC error from the server, e.g. invalid params, surfaces as that exception: it becomes an ordinary error result and doesn't mark the server dead.)
Create `extensions/Volcanic.AgenticCore.Extensions.Mcp/McpTool.cs`:

View File

@@ -29,8 +29,8 @@ two meet only in tool naming (see "Names").
(`o.AddExtension<McpExtension>()`) or dropped into an extensions directory. A small contract gap is closed in
Abstractions (see Components §8). No runtime slice changes.
- **Official SDK, not hand-rolled JSON-RPC.** Use the `ModelContextProtocol.Core` package (the client/transport
core, without the hosting package). It handles the initialize handshake, protocol version negotiation, the
`notifications/cancelled` notification on cancellation, pagination of `tools/list`, content-block types and
core, without the hosting package). It handles the initialize handshake, protocol version negotiation,
pagination of `tools/list`, content-block types and
process shutdown (`StdioClientTransport`: `Command`, `Arguments`, `WorkingDirectory`, `EnvironmentVariables`,
`StandardErrorLines`, `ShutdownTimeout`). The `ClaudeCode.Bridge` hand-rolls its protocol because it answers only
three methods. A general client would have to reimplement much more. SDK types never leave the extension. Its
@@ -237,18 +237,27 @@ One per server per extension instance (that is, per session):
start: expand `${workspace}` from `ctx.Cwd` and `${env:…}`, build the command, cwd and environment through
`ctx.PrepareProcessAsync` (so session-level `ProcessSpawnEvent` handlers run and `TZ` is the chat's zone), create `StdioClientTransport` + `McpClient` under `startupTimeout`, and route
`StandardErrorLines` to `api.Logger` at `stderrLevel` with the prefix `"<name> mcp: "`, keeping a 20-line tail.
- Calls use a linked token: the tool's `CancellationToken` (a session abort) plus `CancelAfter(callTimeout)`. The
SDK sends `notifications/cancelled` for the request. A timeout becomes
- Calls use a linked token: the tool's `CancellationToken` (a session abort) plus `CancelAfter(callTimeout)`. On an
abort or a timeout the connection sends `notifications/cancelled` for the request itself: SDK 2.2.0 doesn't send it
when a request's token fires (checked against the fake server), so each call goes out with its own request id
(`volcanic-<n>`) through `SendRequestAsync` and the notification is sent best effort. A timeout becomes
`"<tool> timed out after <n>s"`, and an abort propagates as `OperationCanceledException`, which the loop already
handles.
- If the process exits or the transport faults, the client is disposed and marked dead, and the call fails with
`"<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.
next call respawns it. There's no automatic retry of the failed call, because it may have had side effects. An exit
is noticed either when the next call finds the SDK session completed or when a call fails and the process ends
within 2 s. The exit code comes from the SDK's `StdioClientCompletionDetails`.
- A JSON-RPC error from the server (e.g. invalid params) is an ordinary error result and doesn't mark the server dead.
- The idle timer (reset per call) disposes the client after `idleTimeout` with no calls in flight. The in-flight count
is taken under the start gate and the idle stop re-checks it under the same gate, so an idle stop never kills a
process a call has just been handed.
- `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.
always takes about `ShutdownTimeout`. After it, calls throw `ObjectDisposedException`. `StopAsync` does the same
but leaves the connection usable. The extension stops connections on `SessionShutdownEvent` for `new`, `resume` and
`fork` (the same extension instance serves the next session) and disposes them for `quit` and `reload`. 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.