Merge branch 'feat/mcp-client-playwright'

This commit is contained in:
EugeneTes
2026-09-26 21:20:52 +02:00
56 changed files with 8525 additions and 53 deletions

View File

@@ -78,8 +78,9 @@ Read `docs/architecture.md` first. Key points:
### Extensions (`extensions/`)
Provider extensions built on the public API only: `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
and `ClaudeCode` + `ClaudeCode.Bridge`. The Claude Code provider is the most intricate piece (`docs/claude-code-provider.md`):
Extensions built on the public API only: `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
`ClaudeCode` + `ClaudeCode.Bridge`, and `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser
preset; `docs/mcp.md`). The Claude Code provider is the most intricate piece (`docs/claude-code-provider.md`):
- Every model call spawns a fresh `claude -p` with built-in tools off and replays the whole transcript as stream-json.
Core tools are exposed as MCP tools `mcp__volcanic__<alias>` through the Bridge, a tiny MCP server that **never
@@ -89,6 +90,8 @@ and `ClaudeCode` + `ClaudeCode.Bridge`. The Claude Code provider is the most int
- Session expiry → `ClaudeLoginCoordinator` runs one login flow at a time through `ClaudeCodeProviderOptions.LoginUi`,
then replays the paused request exactly once. `/claude-code-doctor` and `/claude-code-login` are its commands.
- Tests replay recorded CLI output from `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/*.jsonl`.
- `Mcp`: the tool catalogue is discovered once per host process (`McpCatalogCache.Shared`), each session starts its own
server process on the first call, and tests run `tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer`.
### Telegram host (`hosts/Volcanic.AgenticCore.Telegram`)

View File

@@ -3,6 +3,7 @@
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Anthropic/Volcanic.AgenticCore.Extensions.Anthropic.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
</Folder>
<Folder Name="/hosts/">
@@ -17,6 +18,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
<Project Path="tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" />

View File

@@ -5,7 +5,7 @@
```
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
src/Volcanic.AgenticCore runtime, organised as vertical slices
extensions/* provider extensions built on the public API only
extensions/* provider extensions and the MCP client, built on the public API only
samples/* reference host
tests/* xunit
```
@@ -108,8 +108,9 @@ Two independent event systems exist:
## Processes and secrets
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, and, with their specs, scheduler
scripts and MCP servers) gets its command, cwd and environment from one `IProcessSpawnPolicy`, configured through
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and, with
its spec, scheduler scripts) gets its command, cwd and environment from one `IProcessSpawnPolicy`, configured through
`AgenticCoreOptions.ProcessEnvironment`:
```

View File

@@ -126,8 +126,8 @@ Implement `ITool` directly or derive from `Tool<TArgs>`; the JSON Schema is gene
(records with `[Description]` attributes work well; nullable properties are optional). `DelegateTool<TArgs>`
builds a tool from a lambda.
- Throw to produce an error result. `ToolExecutionException(result)` returns content and details while
marking the result as an error.
- Throw to produce an error result. `ToolExecutionException(result)` (in `Volcanic.AgenticCore.Abstractions.Tools`)
returns content and details while marking the result as an error.
- `ToolResult.Content` is what the model sees; `Details` is structured data for hosts.
- `Terminate = true` on every result of a batch ends the run without another model call.
- `ExecutionMode = Sequential` forces the whole batch to run one at a time (the built-in `edit`/`write` do this).

132
docs/mcp.md Normal file
View File

@@ -0,0 +1,132 @@
# MCP servers and the Playwright browser
`extensions/Volcanic.AgenticCore.Extensions.Mcp` is an MCP **client**: it starts stdio MCP servers and exposes their
tools to the agent as ordinary tools. It uses only the public extension API and the official `ModelContextProtocol.Core`
SDK. (`ClaudeCode.Bridge` is the opposite, an MCP *server* that shows core tools to `claude -p`.)
## Configuration
Servers come from two places. The first one wins a server name:
1. the host's `Mcp` section (`appsettings.json`, or `Mcp__Servers__<name>__Command` and friends in the environment),
sorted by name (configuration binding sorts keys, so the order in `appsettings.json` doesn't count), then the
Playwright preset;
2. `"mcpServers"` in `~/.volcanic/agent/settings.json` (`VOLCANIC_AGENT_DIR` moves it), with the same entry shape in
camelCase, in file order. The extension reads this file itself, so a host that sets `AgenticCoreOptions.AgentDir`
to another directory must also set `VOLCANIC_AGENT_DIR` for `mcpServers` to be found there.
There is no per-workspace source: a Telegram workspace is writable by the agent itself.
```jsonc
"Mcp": {
"Servers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github@2025.9.1"],
"env": { "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" },
"toolPrefix": "gh_",
"enabled": true,
"parallel": false,
"startupTimeoutSeconds": 60,
"callTimeoutSeconds": 120,
"idleTimeoutSeconds": 1800,
"stderrLevel": "Warning",
"promptSection": null
}
},
"Playwright": { "Enabled": false, "Headless": true, "Browser": null, "ExtraArgs": [], "Package": null, "Command": null }
}
```
- `${workspace}` in `command`, `args`, `cwd` and `env` values becomes the chat's workspace when the process starts.
The process runs in `${workspace}` unless `cwd` says otherwise.
- `${env:NAME}` in an `env` value copies a host variable (unset: empty). It's the only way a deny-listed variable such
as `GITHUB_TOKEN` reaches a server, and it reaches only that server. A `null` value removes a variable (use the user
settings file for that; configuration binding may turn `null` into an empty string).
- Every server process gets the process policy's filtered environment (see [extensions.md](extensions.md#spawning-processes)).
Host secrets (`TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, ...) are never inherited, and known secret values in tool output
become `[REDACTED]`.
- `toolPrefix` may contain letters, digits, `_` and `-`, and may not start with `mcp__`. Timeouts must be positive;
`idleTimeoutSeconds: 0` never stops an idle process. The host refuses to start on invalid configuration.
## The Playwright preset
`PLAYWRIGHT_ENABLED=true` (or `Mcp:Playwright:Enabled`) adds a server named `playwright` running `@playwright/mcp`:
```
npx -y @playwright/mcp@0.0.82 [--headless] --browser chromium
--output-dir ${workspace}/browser --user-data-dir ${workspace}/.volcanic/browser-profile <extra args>
```
`--user-data-dir` is left out when the extra arguments contain `--isolated`.
| Variable | Setting | Default | Notes |
|---|---|---|---|
| `PLAYWRIGHT_ENABLED` | `Mcp:Playwright:Enabled` | `false` | |
| `PLAYWRIGHT_HEADLESS` | `Mcp:Playwright:Headless` | `true` | |
| `PLAYWRIGHT_BROWSER` | `Mcp:Playwright:Browser` | `chromium` | Without it `@playwright/mcp` would look for Google Chrome |
| `PLAYWRIGHT_EXTRA_ARGS` | `Mcp:Playwright:ExtraArgs` | — | Shell-style split: `--device "iPhone 15"` is two arguments. `--isolated` keeps no profile on disk |
| `PLAYWRIGHT_COMMAND` | `Mcp:Playwright:Command` | `npx` | Run this binary (e.g. a global `playwright-mcp`) instead of `npx -y <package>` |
| — | `Mcp:Playwright:Package` | `@playwright/mcp@0.0.82` | npm spec used with `npx`. The pinned version is `PlaywrightPreset.DefaultPackage` |
Configured values win over the variables; an empty variable counts as unset. The bot logs
`MCP: playwright enabled (headless, chromium)` or `MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)` at startup.
- Screenshots saved with `filename: "browser/<name>.png"`, PDFs and downloads land in the workspace's `browser/`
folder, where `send` can deliver them. A screenshot without `filename` comes back to the model as an image (the
OpenAI chat-completions provider can't show tool-result images, so there it only gets `[image]`).
- Each chat has its own browser profile under `.volcanic/browser-profile`, so site logins survive idle restarts,
`/new` and bot restarts. The agent's `bash` can read that profile, like everything else in its workspace.
- The system prompt gets a short "Browser" section while the browser tools are active. It mentions `send` only when
a `send` tool exists.
### Deployment requirements
The server needs Node and the Chromium build that the pinned `@playwright/mcp` expects:
`npx playwright install --with-deps chromium`, run from that package so the versions match
(`PLAYWRIGHT_BROWSERS_PATH` is honoured). The first `npx` run downloads the package, which is why the preset allows
120 s to start. The Docker image (`docker.md`, when the docker-deployment spec lands) preinstalls both and sets
`PLAYWRIGHT_COMMAND=playwright-mcp` and `PLAYWRIGHT_EXTRA_ARGS=--no-sandbox`. Chromium's sandbox needs user namespaces,
which Docker's default profile blocks, so there the container is the isolation boundary. A missing browser shows up as
an error from the first `browser_*` call.
## Tool names
The agent sees `toolPrefix + name`, with characters outside `[A-Za-z0-9_-]` replaced by `_`, cut to 48 characters,
and `_2`, `_3` added to duplicates. The server is always called with its own name. Through the Claude Code provider the
tools appear as `mcp__volcanic__<name>`.
- A tool named like a built-in (`read`, `bash`, ...) is **not** registered, and a warning suggests a `toolPrefix`.
- If two servers offer the same name, the first in the order above wins (host servers by name) and a warning names
both. Give one of them a `toolPrefix` to keep both.
- MCP tools run one at a time unless the server sets `"parallel": true`: most servers (Playwright included) are stateful.
## Lifecycle
- The tool list is discovered once per bot process per server configuration, in a temp directory, while the first
session activates. Concurrent chats share that one discovery. A server that fails to start is logged, shows up in
`/mcp` as failed, contributes no tools, and is retried after 60 s. Other servers and the chat carry on.
- Each chat gets its own server process, started by the first call in the chat's workspace. It stops after
`idleTimeoutSeconds` without calls, and on `/new`, `/resume` or shutdown, so a new conversation gets a fresh browser.
- A server that dies turns the call into an error ("… MCP server exited (code): <stderr>. It restarts on the next call;
browser state was lost."), and the next call starts a new one. Failed calls are never retried automatically.
- A call runs at most `callTimeoutSeconds`. `/stop` cancels it and the server is told (`notifications/cancelled`).
## `/mcp`
| Command | |
|---|---|
| `/mcp` | Each server: `not started`, `running`, `exited`, `failed: <reason>` or `disabled`, and its tool count |
| `/mcp <name>` | Command line (without env values), env names, server version, tools, last error, stderr tail |
| `/mcp restart <name>` | Stop the chat's process; the next call starts a fresh one (clears a wedged browser) |
No model call is made. Known secret values in the output (a server's stderr, say) are shown as `[REDACTED]`.
## Manual check (not in CI)
With `PLAYWRIGHT_ENABLED=true` and Chromium installed:
1. "Open https://example.com and tell me the H1" → `Example Domain`.
2. "Screenshot example.com and send it to me" → a photo arrives in Telegram (needs the `send` tool).
3. `/mcp` shows `playwright: running`.
4. Two chats browse at the same time without seeing each other's tabs.

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
# MCP client extension and Playwright browser tools — design
Date: 2026-09-26
Status: draft
Status: implemented (the Dockerfile version test arrives with 2026-09-26-docker-deployment-design.md)
Depends on: `2026-09-26-telegram-attachments-design.md` (the `send` tool that delivers browser output);
`2026-09-26-process-environment-hook-design.md` (the environment every server process gets, and the redaction of
known secrets in tool output).
@@ -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
@@ -63,7 +63,9 @@ two meet only in tool naming (see "Names").
- **MCP output is redacted like any tool's.** MCP tools are ordinary `ITool`s, so the core loop replaces known secret
values (`SecretValues`) with `[REDACTED]` in their results, progress and error messages, including the
`<stderr tail>` of the "MCP server exited" error (process-environment-hook spec §9). A server that was handed a
secret on purpose and echoes it back doesn't put it in the transcript. `McpTool` does nothing itself.
secret on purpose and echoes it back doesn't put it in the transcript. `McpTool` does nothing itself. Text the
extension produces outside a tool result goes through `api.Redactor` too: stderr lines are redacted as they arrive
(before they are logged or kept in the tail), and `/mcp` output is redacted before it is shown or sent.
- **`/new` resets the browser.** Every `SessionShutdownEvent` (quit, `/new`, resume) disposes the session's server
processes, so tabs and in-memory browser state don't survive a new session. That is the intended behaviour: a new
conversation starts with a fresh browser. Logins survive through the persistent profile (next decision).
@@ -103,7 +105,7 @@ two meet only in tool naming (see "Names").
}
```
- `args`, `env` values and an optional `cwd` may contain `${workspace}`, which is expanded to the session cwd when
- `command`, `args`, `env` values and an optional `cwd` may contain `${workspace}`, which is expanded to the session cwd when
the live process starts (and to the temp discovery dir during discovery). The live process's working directory
defaults to `${workspace}`.
- `env` values may also contain `${env:NAME}`, expanded from the host environment at spawn time (unset → empty). This
@@ -111,10 +113,11 @@ two meet only in tool naming (see "Names").
the value is explicit when it reaches the policy.
- The environment a server process gets is `IProcessSpawnPolicy`'s result for a request with `Purpose = McpServer`,
`Origin` = the server name, the expanded command, args and cwd, and the expanded `env` entries as the explicit
environment. It's passed to `StdioClientTransport.EnvironmentVariables` in full, with a null for every inherited
name the policy removed, so the SDK's merge over the host environment can't reintroduce it.
- The catalogue cache key is the server name plus a digest of the **unexpanded** command, args and env, so every chat
shares one catalogue even though their expanded args differ. Secrets from `${env:…}` never enter the key.
environment. It's passed to `StdioClientTransportOptions.EnvironmentVariables` in full with
`InheritEnvironmentVariables = false` (SDK 2.x), so the SDK doesn't merge the host environment back in.
- The catalogue cache key is the server name plus a digest of the **unexpanded** command, args, env, cwd and tool
prefix (the catalogue holds exposed names, which depend on the prefix), so every chat shares one catalogue even
though their expanded args differ. Secrets from `${env:…}` never enter the key.
- `McpOptions.ApplyEnvironment(getEnv)` works like `TelegramOptions.ApplyEnvironment` and fills in the preset from
`PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS` (default true), `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`
(shell-style split honouring quotes, so `--device "iPhone 15"` is two argv entries) and `PLAYWRIGHT_COMMAND`.
@@ -122,9 +125,16 @@ two meet only in tool naming (see "Names").
them all through).
- Validation (`McpOptions.Validate()`) rejects empty commands, non-positive timeouts, and prefixes that don't match
`[A-Za-z0-9_-]*` or that start with `mcp__`.
- Order: `Mcp:Servers` by name, then the Playwright preset (skipped with a warning when a server is
explicitly named `playwright`), then the user settings in file order. The first source wins on a server name.
*Host servers are sorted by name (case-insensitive, then ordinal) because `Microsoft.Extensions.Configuration`
returns child keys sorted anyway, so the order in `appsettings.json` can't be kept; sorting in `McpServerSet`
gives options built in code the same rule.* Server names are compared ordinally everywhere, `/mcp <name>` included. `/mcp` lists a
disabled preset as `playwright: disabled (set PLAYWRIGHT_ENABLED=true)`.
- The extension has two constructors, `McpExtension(McpOptions)` for DI and a parameterless one that reads only
the environment and user settings, so a dropped-in DLL works without host code. The agent dir is
`VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`.
`VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`, regardless of `AgenticCoreOptions.AgentDir` (the extension API doesn't
expose the host's agent dir; exposing it is a separate, additive change).
### 2. Playwright preset — `PlaywrightPreset`
@@ -133,7 +143,7 @@ When enabled, it expands to server `playwright`:
```
command: npx (or Playwright:Command / PLAYWRIGHT_COMMAND, e.g. the globally installed `playwright-mcp`)
args: -y @playwright/mcp@<pinned> (only with npx; Playwright:Package overrides the spec)
[--headless] [--browser <b>]
[--headless] --browser <b, default chromium>
--output-dir ${workspace}/browser
--user-data-dir ${workspace}/.volcanic/browser-profile
<ExtraArgs...>
@@ -150,6 +160,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
(hence the 120 s startup timeout for the first download). The Docker image preinstalls the pinned package globally
and compose sets `PLAYWRIGHT_COMMAND=playwright-mcp`, so a container never downloads anything and the first chat
doesn't stall.
- Without `--browser`, `@playwright/mcp` uses the Chrome channel, which the deployment doesn't install (`playwright
install chromium` provides Chromium). The preset therefore passes `--browser chromium` unless `Browser` is set.
With `--isolated` in `ExtraArgs` the preset leaves out `--user-data-dir`.
- **Chromium sandbox.** The preset doesn't add `--no-sandbox` itself. Chromium's sandbox needs user namespaces, which
Docker's default seccomp profile blocks for a non-root user, so the Docker compose file sets
`PLAYWRIGHT_EXTRA_ARGS=--no-sandbox` and the container is the isolation boundary. Bare hosts keep the sandbox.
@@ -165,8 +178,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
process environment hook, which keeps `PLAYWRIGHT_BROWSERS_PATH`, so it applies. In the container that's `/opt/ms-playwright`, set in the image. A missing browser
shows up as an error result from the first `browser_*` call (the MCP server itself starts fine), and the model
reports it.
- `PromptSection` (added through `BeforeAgentStartEvent` → `SystemPromptOptions.Sections["browser"]` while any
`browser_*` tool is active):
- `PromptSection` (added through `BeforeAgentStartEvent` → `SystemPromptOptions.Sections["browser"]` while any of
the server's registered tools is active (for the preset, the `browser_*` tools); a server's `promptSection` works
the same way under its own name):
```
## Browser
@@ -181,16 +195,22 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
```
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
`GetAllTools()`), so the preset still reads correctly on hosts without attachments.
`GetAllTools()`), so the preset still reads correctly on hosts without attachments. Without `send`, that bullet
reads: "Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are
under browser/ in the workspace."
### 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.
@@ -208,9 +228,10 @@ provider's aliasing:
- The MCP server is always called with the original `mcpName`.
- Collisions with other tools follow the existing `ToolRegistry` rules. An MCP tool named like a built-in
(`read`, `bash`, ...) would **replace** it, so `McpExtension` refuses to register an exposed name that
`api.GetAllTools()` already reports with source `built-in`. It logs a warning suggesting a `toolPrefix`. A clash
`api.GetAllTools()` already reports with source `built-in`. It logs a warning suggesting a `toolPrefix`. The
built-in check runs against `GetAllTools()` at activation, so it covers every built-in, active or not. A clash
with another extension's tool is already ignored and recorded by the runner. Between two MCP servers, the first
configured wins, and a warning names both.
in the order of §1 wins (host servers by name, then the preset, then the user settings), and a warning names both.
- Through the Claude Code provider these tools reach the model as `mcp__volcanic__<exposedName>`. Nothing else is
needed: the Bridge advertises them from the catalogue like any other tool and never executes them. Prefixes that
start with `mcp__` are rejected (they'd read as `mcp__volcanic__mcp__x__y`). Because Claude Code runs with
@@ -224,17 +245,29 @@ 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.
- `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,
resume). A new session in the same chat starts from a fresh process, and the persistent profile keeps the logins.
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. 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`. Only
when the SDK reports no exit code does the connection kill the tree itself, and only if the process under that pid
started while this server was being spawned (a pid can be reused by an unrelated process once ours is gone). After it, calls throw `ObjectDisposedException`. `StopAsync` does the same
but leaves the connection usable, and resets a failed status to `not started` (the last error stays for
`/mcp <name>`). 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`, all servers concurrently. 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.
@@ -279,8 +312,13 @@ One per server per extension instance (that is, per session):
### 9. The command — `/mcp`
Lists each configured server for this session: state (`not started` / `running pid N` / `failed: <reason>` /
`disabled`), number of tools, last error, and the stderr tail with `/mcp <name>`. It costs no model call.
Lists each configured server for this session with its state and number of tools. It costs no model call. States:
`not started` / `running` / `exited (restarts on the next call)` (the process ended since the last call) /
`failed: <reason>` / `disabled` (the preset adds `(set PLAYWRIGHT_ENABLED=true)`). The SDK
doesn't expose a live server's pid, so `/mcp` shows none. `/mcp <name>` shows the unexpanded command, the names (never
the values) of its `env` entries, server info, tools, the last error and the stderr tail. With a UI the output is a
notification; without one it is a `mcp` custom message appended without a turn. Either way the text goes through
`api.Redactor` first (identity when the API has none), because the last error and stderr tail can hold a secret.
`/mcp restart <name>` disposes the session process so the next call starts fresh (clears a wedged browser).
### 10. Host wiring (Telegram)
@@ -310,15 +348,21 @@ At startup the log reads `MCP: playwright enabled (headless, chromium)` or
No test touches the network, npm or a real browser.
- **Fake stdio server** `tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer`: a small console app built
with the SDK's server package and launched with `dotnet <dll>`. Its behaviour is chosen by env var
`FAKE_MCP_MODE`:
- `normal`: `echo`, `image` (1×1 PNG), `fail` (returns `isError`), `slow` (awaits cancellation), `weird.name/x`, and
a tool with no `inputSchema`;
with the SDK's server API (in `ModelContextProtocol.Core`, no hosting package) and launched with `dotnet <dll>`.
Its behaviour is chosen by env var `FAKE_MCP_MODE` (comma-separated, so modes combine):
- `normal`: `echo`, `image` (1×1 PNG), `fail` (returns `isError`), `slow` (awaits cancellation and logs
`{"event":"cancelled"}` to `$FAKE_MCP_LOG` when cancelled), `weird.name/x`, `weird_name_x` (collides with
`weird.name/x` after sanitising), a tool whose name is longer than 48 characters, and a tool with no `inputSchema`;
- `crash-on-start`: writes a traceback to stderr and exits 1;
- `hang-on-start`: never answers `initialize`;
- `die-after-first-call`: exits after one call;
- `die-after-first-call`: about 200 ms after answering the first call, writes `dying: $FAKE_MCP_DIE_MESSAGE` to
stderr and exits 3;
- `env`: adds a tool that returns the server's environment as text;
- `count-starts`: appends a line to `$FAKE_MCP_LOG` per start, to count spawns.
- `builtin-clash`: adds a tool named `read`;
- `count-starts`: appends a line to `$FAKE_MCP_LOG` per start, to count spawns, with the pid, cwd, args and (with
`env`) the environment.
- With `--output-dir` in its argv it also offers `browser_navigate` and `browser_take_screenshot` (a `filename`
is written relative to its cwd), so the Playwright preset can run against the fake.
- **Tests** (`tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/`):
- catalogue: names, descriptions and schemas pass through verbatim; the MCP name is used on the wire; a prefix is
applied; `weird.name/x` → `weird_name_x`; >48 chars truncated; duplicates suffixed; a missing schema becomes
@@ -331,7 +375,7 @@ No test touches the network, npm or a real browser.
- environment (`FAKE_MCP_MODE=env`, a tool that returns its environment): with `OPENAI_API_KEY` and `GITHUB_TOKEN`
in the injected host snapshot, neither reaches the server; a server `env` entry `GITHUB_TOKEN: ${env:GITHUB_TOKEN}`
delivers it (explicit beats the deny-list); discovery and the session process agree; `TZ` is the session's zone
for the session process;
for the session process (on Unix; the policy never sets `TZ` on Windows, so there the test checks it's absent);
- results: text, image → `ImageContent`, oversized image replaced, `isError` → error tool result with content,
output truncation note;
- redaction, end to end through the core: the `env` tool's output with a `GITHUB_TOKEN` configured as a secret
@@ -343,12 +387,13 @@ No test touches the network, npm or a real browser.
call succeeds on a new process; idle timeout stops the process;
- lifecycle: `SessionShutdownEvent` kills the process (checked by pid); dispose is idempotent;
- collisions: a server tool named `read` is refused and the built-in stays; two servers with the same tool → the
first wins;
first by name wins, and configuration binding delivers `Mcp:Servers` sorted by name;
- config: `ApplyEnvironment` (quoted `PLAYWRIGHT_EXTRA_ARGS`), config over env, `Validate`, user
`settings.json` merge and precedence;
- Playwright preset: argv for headless/browser/extra args, output dir and profile under the workspace, a pinned
package; with `PLAYWRIGHT_COMMAND` the binary is used and `-y <package>` is dropped; the Dockerfile's
`PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version; the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists;
`PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version (deferred: the Dockerfile and that test come with the
docker-deployment spec); the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists;
- end to end through the core with `FauxProvider`: the scripted model calls the fake `image` tool, and the tool
result message holds the image.
- **Claude Code**: a `ToolCatalog` test that `browser_navigate`-style names alias to themselves (no digest).

View File

@@ -1,7 +1,7 @@
# Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design
Date: 2026-09-26
Status: implemented (the scheduler `ScriptRunner` and the MCP spawners in §5 come with their own specs)
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md; the scheduler `ScriptRunner` comes with its own spec)
Used by: `2026-09-26-scheduler-design.md` (watcher scripts), `2026-09-26-mcp-client-playwright-design.md` (MCP
server processes), `2026-09-26-telegram-attachments-design.md` (`OPENAI_API_KEY`), `2026-09-26-google-calendar-design.md`
(`GOOGLE_CLIENT_SECRET`), `2026-09-26-docker-deployment-design.md` (the secrets set in Coolify).
@@ -227,9 +227,11 @@ Process-wide spawners without a session (the scheduler, MCP catalogue discovery)
| `BashTool` | `BashTool`, `Command` = the model's command, `Cwd` = `ctx.Cwd` | none | Through `ctx.PrepareProcessAsync`. Then it runs the shell as today with the returned command and cwd, and a cleared-then-copied environment. |
| `ExtensionApi.ExecAsync` | `ExtensionExec`, `Origin` = extension name | `ExecOptions.Environment` | Session-bound. `ExecOptions.Environment` keeps its meaning (set, or null to remove), now on top of the filtered base. |
| Scheduler `ScriptRunner` | `ScheduledScript`, `Scope` = target, `Origin` = job id, `TimeZoneId` = the target's zone | none | Through the DI `IProcessSpawnPolicy`: jobs fire for chats with no live session. Replaces `SchedulerOptions.ScrubbedEnvironment`. |
| MCP `McpServerConnection` | `McpServer`, `Origin` = server name, `Arguments` = expanded args | the server's `env` entries | Through `ctx.PrepareProcessAsync` from the first tool call. The result's env goes to `StdioClientTransport.EnvironmentVariables`, with a null for every inherited name it doesn't contain. |
| MCP `McpServerConnection` | `McpServer`, `Origin` = server name, `Arguments` = expanded args | the server's `env` entries | Through `ctx.PrepareProcessAsync` from the first tool call. The result's env goes to `StdioClientTransportOptions.EnvironmentVariables`, with `InheritEnvironmentVariables = false`. |
| MCP `McpCatalogCache` discovery | as above, `Cwd` = the temp dir | the server's `env` entries | Through `api.Processes.Prepare` (activation, no session). |
*The two MCP rows are implemented in `extensions/Volcanic.AgenticCore.Extensions.Mcp` (`McpServerConnection`, `McpCatalogCache`).*
A spawner whose `Prepare` throws reports it the way it reports a failed start: `bash` returns an error result
`process environment hook failed: <message>`, a watcher is `Failed(null, "…")`, an MCP call or discovery fails with the
message. The message comes from the exception, never from the environment.

View File

@@ -36,6 +36,9 @@ Environment variables, or the `Telegram` section of `appsettings.json` (`Telegra
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through to `bash`, scheduled scripts and MCP servers despite the deny-list (e.g. `GH_TOKEN` for `gh`). Added to `ProcessEnvironment:Allow` |
| `ProcessEnvironment:Deny` / `ProcessEnvironment:Allow` | built-in deny-list | Added to the defaults (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, their `Section__Key` forms and the named provider credentials), never replacing them. Allowing a name that is denied by name stops startup |
| `ProcessEnvironment:HardenHostProcess` | `true` | Linux: the bot process is made non-dumpable. `ProcessEnvironment__HardenHostProcess=false` for a debugging session only |
| `PLAYWRIGHT_ENABLED` / `Mcp:Playwright:Enabled` | `false` | Browser tools through `@playwright/mcp` ([mcp.md](mcp.md)) |
| `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` | `true`, `chromium`, —, `npx` | See [mcp.md](mcp.md#the-playwright-preset) |
| `Mcp:Servers:<name>` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) |
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
its most recent conversation.
@@ -84,7 +87,7 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and
| `/help` | Short help |
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` and
`/claude-code-login` work.
`/claude-code-login` work. `/mcp` lists the MCP servers (see [mcp.md](mcp.md#mcp)).
When an extension asks something, the question appears with buttons (or as a reply prompt for free text).
If the Claude subscription session behind the bot expires, the **first id in `AllowedUserIds`** (the bot owner)

View File

@@ -0,0 +1,24 @@
using System.Text.Json;
using ModelContextProtocol.Protocol;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>One server tool: the name on the wire, the name the agent sees, and its schema verbatim.</summary>
internal sealed record McpCatalogTool(string McpName, string ExposedName, string? Description, string? Title, JsonElement InputSchema);
/// <summary>A server's <c>tools/list</c> result, discovered once per host process.</summary>
internal sealed record McpCatalog(string ServerName, string? ServerInfoName, string? ServerVersion, IReadOnlyList<McpCatalogTool> Tools)
{
public static McpCatalog From(string serverName, McpServerConfig config, Implementation? info, IReadOnlyList<Tool> tools)
{
var exposed = ToolNaming.Expose([.. tools.Select(t => t.Name)], config.ToolPrefix);
return new McpCatalog(serverName, info?.Name, info?.Version,
[.. tools.Select((t, i) => new McpCatalogTool(t.Name, exposed[i], t.Description, t.Annotations?.Title ?? t.Title, t.InputSchema.Clone()))]);
}
}
/// <summary>A server could not be started or didn't answer the handshake. The message includes its stderr tail.</summary>
internal sealed class McpServerStartException(string message, Exception? inner = null) : Exception(message, inner);
/// <summary>A running server exited; the next call starts a new process.</summary>
internal sealed class McpServerExitedException(string message) : Exception(message);

View File

@@ -0,0 +1,124 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// Process-wide cache of <c>tools/list</c> results, keyed by server name and a digest of its unexpanded configuration.
/// Concurrent requests share one discovery. Success is kept for the process lifetime, failure for <see cref="FailureCooldown"/>.
/// </summary>
public sealed class McpCatalogCache
{
/// <summary>How long a failed discovery is remembered before the next request tries again.</summary>
public static readonly TimeSpan FailureCooldown = TimeSpan.FromSeconds(60);
/// <summary>Explicit process-wide state, like <c>ClaudeLoginCoordinator.Shared</c>.</summary>
public static McpCatalogCache Shared { get; } = new(TimeProvider.System);
private readonly TimeProvider _time;
private readonly Lock _lock = new();
private readonly Dictionary<string, Task<McpCatalog>> _tasks = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _failedAt = new(StringComparer.Ordinal);
internal McpCatalogCache(TimeProvider time) => _time = time;
/// <summary>
/// The server's catalogue, discovering it if nothing is cached or a failure's cooldown has passed. The shared discovery
/// runs detached from any caller's token; each caller waits with its own. Throws <see cref="McpServerStartException"/>.
/// <paramref name="redact"/> is applied to the server's stderr before it is logged or put in an error message.
/// </summary>
internal Task<McpCatalog> GetAsync(string name, McpServerConfig config, IProcessSpawnPolicy processes, Func<string, string?> getEnv, ILogger logger, CancellationToken cancellationToken, Func<string, string>? redact = null)
{
var key = McpLaunch.CacheKey(name, config);
Task<McpCatalog> task;
lock (_lock)
{
_tasks.TryGetValue(key, out var existing);
var expired = existing is { IsFaulted: true }
&& _failedAt.TryGetValue(key, out var failedAt)
&& _time.GetUtcNow() - failedAt >= FailureCooldown;
if (existing is null || expired)
{
_failedAt.Remove(key);
existing = Task.Run(() => DiscoverAsync(key, name, config, processes, getEnv, logger, redact), CancellationToken.None);
_tasks[key] = existing;
}
task = existing;
}
return task.WaitAsync(cancellationToken);
}
private async Task<McpCatalog> DiscoverAsync(string key, string name, McpServerConfig config, IProcessSpawnPolicy processes, Func<string, string?> getEnv, ILogger logger, Func<string, string>? redact)
{
var dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-discovery", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var stderr = new StderrTail(redact);
McpProcess? process = null;
try
{
ProcessSpawnContext spawn;
try { spawn = processes.Prepare(McpLaunch.BuildRequest(name, config, dir, getEnv)); }
catch (Exception ex) { throw new McpServerStartException($"{name}: process environment hook failed: {ex.Message}", ex); }
process = await McpProcess.StartAsync(name, config, spawn, stderr, logger, CancellationToken.None).ConfigureAwait(false);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(config.StartupTimeoutSeconds));
IList<McpClientTool> tools;
try { tools = await process.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException)
{
throw new McpServerStartException($"{name} MCP server did not list its tools within {config.StartupTimeoutSeconds}s{McpProcess.TailSuffix(stderr)}");
}
catch (Exception ex)
{
throw new McpServerStartException($"{name} MCP server failed to list its tools: {ex.Message}{McpProcess.TailSuffix(stderr)}", ex);
}
var catalog = McpCatalog.From(name, config, process.Client.ServerInfo, [.. tools.Select(t => t.ProtocolTool)]);
logger.LogInformation("MCP server {Name}: {Count} tools ({Server} {Version})", name, catalog.Tools.Count, catalog.ServerInfoName, catalog.ServerVersion);
return catalog;
}
catch (Exception ex)
{
lock (_lock) _failedAt[key] = _time.GetUtcNow(); // recorded before the task faults
logger.LogWarning("MCP server {Name} is unavailable: {Message}", name, redact is null ? ex.Message : redact(ex.Message));
if (ex is McpServerStartException) throw;
throw new McpServerStartException($"{name} MCP server discovery failed: {ex.Message}", ex);
}
finally
{
// Detached: the SDK spends ShutdownTimeout on every dispose, and no caller needs to wait for it.
_ = Task.Run(() => CleanUpAsync(name, process, dir, logger), CancellationToken.None);
}
}
private static async Task CleanUpAsync(string name, McpProcess? process, string dir, ILogger logger)
{
try
{
if (process is not null) await process.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogDebug(ex, "MCP server {Name}: stopping the discovery process failed", name);
}
// Windows can hold the cwd for a moment after the process exits.
for (var attempt = 1; ; attempt++)
{
try
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
if (attempt == 5)
{
logger.LogDebug(ex, "MCP server {Name}: could not delete the discovery directory {Dir}", name, dir);
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt)).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Text;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary><c>/mcp</c>: server states, <c>/mcp &lt;name&gt;</c> details, <c>/mcp restart &lt;name&gt;</c>. No model call.</summary>
internal static class McpCommand
{
public const string Description = "MCP servers: /mcp, /mcp <name> for details, /mcp restart <name>";
/// <summary>The text goes through <paramref name="redact"/>: it can hold a server's stderr and last error.</summary>
public static CommandDefinition Create(IExtensionApi api, IReadOnlyList<McpServerState> servers, Func<string, string> redact) => new(Description, async (args, ctx, _) =>
{
var text = redact(await RunAsync(args, servers).ConfigureAwait(false));
if (ctx.HasUi) ctx.Ui.Notify(text);
else api.SendMessage(CustomMessage.FromText(McpExtension.CommandName, text), MessageDelivery.Steer, triggerTurn: false);
});
public static async Task<string> RunAsync(string args, IReadOnlyList<McpServerState> servers)
{
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0) return Summary(servers);
if (parts[0] == "restart")
{
if (parts.Length < 2) return "Usage: /mcp restart <name>";
if (Find(servers, parts[1]) is not { } target) return Unknown(parts[1], servers);
if (target.Connection is not { } connection) return $"{target.Entry.Name} has no process to restart ({Status(target)}).";
await connection.StopAsync().ConfigureAwait(false);
return $"Stopped {target.Entry.Name}; the next call starts a fresh process.";
}
return Find(servers, parts[0]) is { } server ? Details(server) : Unknown(parts[0], servers);
}
public static string Status(McpServerState server)
{
if (server.Entry.Disabled) return server.Entry.DisabledHint is { } hint ? $"disabled ({hint})" : "disabled";
if (server.Error is { } error) return $"failed: {FirstLine(error)}";
return server.Connection!.Status switch
{
McpConnectionStatus.Running when server.Connection.ProcessExited => "exited (restarts on the next call)",
McpConnectionStatus.Running => "running",
McpConnectionStatus.Failed => $"failed: {FirstLine(server.Connection.LastError ?? "unknown error")}",
_ => "not started",
};
}
private static string Summary(IReadOnlyList<McpServerState> servers)
{
var sb = new StringBuilder("MCP servers:");
foreach (var server in servers)
sb.Append($"\n- {server.Entry.Name}: {Status(server)}{(server.Entry.Disabled ? "" : $" · {server.RegisteredTools.Count} tools")}");
return sb.ToString();
}
private static string Details(McpServerState server)
{
var config = server.Entry.Config;
var sb = new StringBuilder($"**{server.Entry.Name}**: {Status(server)} · {server.RegisteredTools.Count} tools");
if (!server.Entry.Disabled)
{
sb.Append($"\nCommand: {string.Join(' ', [config.Command, .. config.Args])}");
if (config.Env.Count > 0) sb.Append($"\nEnvironment: {string.Join(", ", config.Env.Keys)}"); // names only, never values
}
if (server.Catalog is { } catalog) sb.Append($"\nServer: {catalog.ServerInfoName} {catalog.ServerVersion}");
if (server.RegisteredTools.Count > 0) sb.Append($"\nTools: {string.Join(", ", server.RegisteredTools)}");
var lastError = server.Error ?? server.Connection?.LastError;
if (lastError is not null) sb.Append($"\nLast error: {lastError}");
if (server.Connection?.StderrTail is { Count: > 0 } tail) sb.Append($"\nStderr (last {tail.Count} lines):\n```\n{string.Join('\n', tail)}\n```");
return sb.ToString();
}
private static McpServerState? Find(IReadOnlyList<McpServerState> servers, string name) =>
servers.FirstOrDefault(s => string.Equals(s.Entry.Name, name, StringComparison.Ordinal));
private static string Unknown(string name, IReadOnlyList<McpServerState> servers) =>
$"No MCP server named `{name}`. Servers: {string.Join(", ", servers.Select(s => s.Entry.Name))}.";
private static string FirstLine(string text) => text.Split('\n', 2)[0];
}

View File

@@ -0,0 +1,156 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// MCP client: registers the tools of the configured stdio MCP servers. The catalogue is discovered once per host process;
/// each session gets its own server process, started on the first call.
/// </summary>
public sealed class McpExtension : IExtension
{
public const string CommandName = "mcp";
private readonly McpOptions _options;
private readonly McpCatalogCache _cache;
private readonly Func<string, string?> _getEnv;
private readonly TimeProvider _time;
private readonly List<McpServerState> _servers = [];
/// <summary>For DI: the host binds <see cref="McpOptions"/>, applies the environment and validates it.</summary>
public McpExtension(McpOptions options)
: this(options, McpCatalogCache.Shared, Environment.GetEnvironmentVariable, TimeProvider.System) { }
/// <summary>For a dropped-in assembly: only the <c>PLAYWRIGHT_*</c> variables and the user settings.</summary>
public McpExtension()
: this(FromEnvironment(Environment.GetEnvironmentVariable), McpCatalogCache.Shared, Environment.GetEnvironmentVariable, TimeProvider.System) { }
internal McpExtension(McpOptions options, McpCatalogCache cache, Func<string, string?> getEnv, TimeProvider time)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_cache = cache;
_getEnv = getEnv;
_time = time;
}
public string Name => "mcp";
internal McpOptions Options => _options;
public async ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
var logger = api.Logger;
var redact = RedactorOf(api);
var entries = McpServerSet.Resolve(_options, McpUserSettings.Load(McpUserSettings.AgentDir(_getEnv), logger), logger);
var states = await Task.WhenAll(entries.Select(e => PrepareAsync(e, api, redact, cancellationToken))).ConfigureAwait(false);
_servers.AddRange(states);
RegisterTools(api);
api.RegisterCommand(CommandName, McpCommand.Create(api, _servers, redact));
api.On<BeforeAgentStartEvent>((e, _, _) =>
{
AddPromptSections(api, e.SystemPromptOptions);
return ValueTask.CompletedTask;
});
api.On<SessionShutdownEvent>(async (e, _, _) =>
{
var final = e.Reason is SessionShutdownReason.Quit or SessionShutdownReason.Reload;
// Concurrently: SDK 2.2 spends ShutdownTimeout on every live server it disposes.
await Task.WhenAll(_servers
.Where(s => s.Connection is not null)
.Select(s => final ? s.Connection!.DisposeAsync().AsTask() : s.Connection!.StopAsync().AsTask())).ConfigureAwait(false);
});
}
/// <summary>The host's redactor, or identity for an API without one (it then has no secrets to hide).</summary>
internal static Func<string, string> RedactorOf(IExtensionApi api)
{
try
{
var redactor = api.Redactor;
return redactor.Redact;
}
catch (NotSupportedException)
{
return static text => text;
}
}
private async Task<McpServerState> PrepareAsync(McpServerEntry entry, IExtensionApi api, Func<string, string> redact, CancellationToken cancellationToken)
{
if (entry.Disabled) return new McpServerState(entry);
if (entry.Problem is { } problem)
{
api.Logger.LogError("MCP server {Name} is misconfigured: {Problem}", entry.Name, problem);
return new McpServerState(entry) { Error = problem };
}
try
{
var catalog = await _cache.GetAsync(entry.Name, entry.Config, api.Processes, _getEnv, api.Logger, cancellationToken, redact).ConfigureAwait(false);
return new McpServerState(entry)
{
Catalog = catalog,
Connection = new McpServerConnection(entry.Name, entry.Config, api.Logger, _getEnv, catalog, _time, redact),
};
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new McpServerState(entry) { Error = ex.Message }; // the cache has logged it
}
}
private void RegisterTools(IExtensionApi api)
{
var builtIns = api.GetAllTools().Where(t => t.Source == "built-in").Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
var owners = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var server in _servers)
{
if (server.Catalog is not { } catalog || server.Connection is not { } connection) continue;
foreach (var tool in catalog.Tools)
{
if (builtIns.Contains(tool.ExposedName))
{
api.Logger.LogWarning("MCP server {Server}: tool {Tool} would replace the built-in tool of the same name and is not registered; set a toolPrefix for this server.", server.Entry.Name, tool.ExposedName);
continue;
}
if (owners.TryGetValue(tool.ExposedName, out var owner))
{
api.Logger.LogWarning("MCP tool {Tool} is offered by servers {Servers}; the first one wins.", tool.ExposedName, $"{owner} and {server.Entry.Name}");
continue;
}
owners[tool.ExposedName] = server.Entry.Name;
api.RegisterTool(new McpTool(tool, connection, server.Entry.Config.Parallel));
server.RegisteredTools.Add(tool.ExposedName);
}
}
}
private void AddPromptSections(IExtensionApi api, SystemPromptOptions options)
{
var active = api.GetActiveTools().ToHashSet(StringComparer.Ordinal);
var hasSend = api.GetAllTools().Any(t => t.Name == "send");
foreach (var server in _servers)
{
if (!server.RegisteredTools.Any(active.Contains)) continue;
if (server.Entry.PromptSection(hasSend) is { Length: > 0 } section) options.Sections[server.Entry.SectionKey] = section;
}
}
private static McpOptions FromEnvironment(Func<string, string?> getEnv)
{
var options = new McpOptions();
options.ApplyEnvironment(getEnv);
return options;
}
}
/// <summary>One server in one session: its catalogue (if discovery worked), its connection and the tools it got.</summary>
internal sealed class McpServerState(McpServerEntry entry)
{
public McpServerEntry Entry { get; } = entry;
public McpCatalog? Catalog { get; init; }
public McpServerConnection? Connection { get; init; }
public string? Error { get; init; }
public List<string> RegisteredTools { get; } = [];
}

View File

@@ -0,0 +1,64 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ModelContextProtocol.Client;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Turns a server config into a spawn request and the SDK's transport options.</summary>
internal static partial class McpLaunch
{
public const string WorkspaceToken = "${workspace}";
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5);
public static string ExpandWorkspace(string value, string workspace) =>
value.Replace(WorkspaceToken, workspace, StringComparison.Ordinal);
/// <summary><c>${workspace}</c>, then <c>${env:NAME}</c> from the host (unset → empty). Only env values get the second.</summary>
public static string ExpandEnvValue(string value, string workspace, Func<string, string?> getEnv) =>
EnvReference().Replace(ExpandWorkspace(value, workspace), m => getEnv(m.Groups[1].Value) ?? "");
public static ProcessSpawnRequest BuildRequest(string name, McpServerConfig config, string workspace, Func<string, string?> getEnv) => new()
{
Purpose = ProcessPurpose.McpServer,
Command = ExpandWorkspace(config.Command, workspace),
Arguments = [.. config.Args.Select(a => ExpandWorkspace(a, workspace))],
Cwd = config.Cwd is { Length: > 0 } cwd ? ExpandWorkspace(cwd, workspace) : workspace,
Environment = config.Env.Count == 0
? null
: config.Env.ToDictionary(kv => kv.Key, kv => kv.Value is null ? null : ExpandEnvValue(kv.Value, workspace, getEnv), StringComparer.Ordinal),
Origin = name,
};
/// <summary>The spawn's environment is complete (process policy), so the SDK must not merge the host's back in.</summary>
public static StdioClientTransportOptions TransportOptions(string name, ProcessSpawnContext spawn, Action<string> onStderrLine) => new()
{
Name = name,
Command = spawn.Command,
Arguments = [.. spawn.Arguments],
WorkingDirectory = spawn.Cwd,
InheritEnvironmentVariables = false,
EnvironmentVariables = new Dictionary<string, string?>(spawn.Environment.Select(kv => KeyValuePair.Create(kv.Key, (string?)kv.Value))),
ShutdownTimeout = ShutdownTimeout,
StandardErrorLines = onStderrLine,
};
/// <summary>Server name plus a digest of the unexpanded command, args, env, cwd and prefix: every chat shares it, and no host secret enters it.</summary>
public static string CacheKey(string name, McpServerConfig config)
{
var canonical = JsonSerializer.Serialize(new
{
config.Command,
config.Args,
Env = config.Env.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => new[] { kv.Key, kv.Value }).ToArray(),
config.Cwd,
config.ToolPrefix,
});
return $"{name}:{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))}";
}
[GeneratedRegex(@"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}")]
private static partial Regex EnvReference();
}

View File

@@ -0,0 +1,119 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>MCP client configuration: the <c>Mcp</c> section of the host configuration, plus the <c>PLAYWRIGHT_*</c> variables.</summary>
public sealed class McpOptions
{
public const string SectionName = "Mcp";
private readonly List<string> _environmentErrors = [];
/// <summary>
/// Servers by name. They are taken in name order (configuration binding sorts keys, so the order in appsettings.json
/// is lost); the first by name wins a tool name. Set a <c>toolPrefix</c> to keep both.
/// </summary>
public Dictionary<string, McpServerConfig> Servers { get; set; } = new(StringComparer.Ordinal);
/// <summary>The Playwright preset. Expands into a server named <c>playwright</c> when enabled.</summary>
public PlaywrightOptions Playwright { get; set; } = new();
/// <summary>
/// Fills unset preset values from <c>PLAYWRIGHT_ENABLED</c>, <c>PLAYWRIGHT_HEADLESS</c>, <c>PLAYWRIGHT_BROWSER</c>,
/// <c>PLAYWRIGHT_EXTRA_ARGS</c> (shell-style split) and <c>PLAYWRIGHT_COMMAND</c>. Configured values win; an empty
/// variable counts as unset. Problems are reported by <see cref="Validate"/>.
/// </summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(getEnv);
string? Get(string name) => getEnv(name) is { Length: > 0 } value ? value : null;
var p = Playwright;
if (p.Enabled is null && Get("PLAYWRIGHT_ENABLED") is { } enabled) p.Enabled = ParseBool("PLAYWRIGHT_ENABLED", enabled);
if (p.Headless is null && Get("PLAYWRIGHT_HEADLESS") is { } headless) p.Headless = ParseBool("PLAYWRIGHT_HEADLESS", headless);
p.Browser ??= Get("PLAYWRIGHT_BROWSER");
p.Command ??= Get("PLAYWRIGHT_COMMAND");
if (p.ExtraArgs is not { Count: > 0 } && Get("PLAYWRIGHT_EXTRA_ARGS") is { } extra)
{
try { p.ExtraArgs = [.. ShellWords.Split(extra)]; }
catch (FormatException ex) { _environmentErrors.Add($"PLAYWRIGHT_EXTRA_ARGS: {ex.Message}."); }
}
}
private bool? ParseBool(string name, string value)
{
if (bool.TryParse(value, out var parsed)) return parsed;
if (value == "1") return true;
if (value == "0") return false;
_environmentErrors.Add($"{name} must be true or false, got '{value}'.");
return null;
}
/// <summary>Configuration errors: environment parsing problems and every server's own errors.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>(_environmentErrors);
foreach (var (name, server) in Servers) errors.AddRange(server.Validate(name));
return errors;
}
/// <summary>The startup log line: <c>MCP: playwright enabled (headless, chromium)</c> or how to enable it.</summary>
public string DescribePlaywright() => PlaywrightPreset.Describe(Playwright);
}
/// <summary>One stdio MCP server. <c>${workspace}</c> expands in command, args, cwd and env; <c>${env:NAME}</c> in env values.</summary>
public sealed partial class McpServerConfig
{
public string Command { get; set; } = "";
public List<string> Args { get; set; } = [];
/// <summary>Applied over the filtered environment and never filtered. A null value removes the variable.</summary>
public Dictionary<string, string?> Env { get; set; } = new(StringComparer.Ordinal);
/// <summary>Working directory of the session process. Defaults to <c>${workspace}</c>.</summary>
public string? Cwd { get; set; }
public string? ToolPrefix { get; set; }
public bool Enabled { get; set; } = true;
public bool Parallel { get; set; }
public int StartupTimeoutSeconds { get; set; } = 60;
public int CallTimeoutSeconds { get; set; } = 120;
/// <summary>0 means an idle process is never stopped.</summary>
public int IdleTimeoutSeconds { get; set; } = 1800;
public LogLevel StderrLevel { get; set; } = LogLevel.Warning;
/// <summary>Added to the system prompt while any of this server's tools is active.</summary>
public string? PromptSection { get; set; }
public IReadOnlyList<string> Validate(string name)
{
var where = $"Mcp:Servers:{name}";
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(name)) errors.Add("Mcp:Servers: a server name is empty.");
if (string.IsNullOrWhiteSpace(Command)) errors.Add($"{where}: command is required.");
if (StartupTimeoutSeconds <= 0) errors.Add($"{where}: startupTimeoutSeconds must be positive.");
if (CallTimeoutSeconds <= 0) errors.Add($"{where}: callTimeoutSeconds must be positive.");
if (IdleTimeoutSeconds < 0) errors.Add($"{where}: idleTimeoutSeconds must be 0 (never) or positive.");
if (ToolPrefix is { Length: > 0 } prefix)
{
if (!PrefixPattern().IsMatch(prefix)) errors.Add($"{where}: toolPrefix may only contain letters, digits, '_' and '-'.");
else if (prefix.StartsWith("mcp__", StringComparison.OrdinalIgnoreCase)) errors.Add($"{where}: toolPrefix must not start with mcp__.");
}
return errors;
}
[GeneratedRegex("^[A-Za-z0-9_-]*$")]
private static partial Regex PrefixPattern();
}
/// <summary>The Playwright preset. Null means "not configured" so the environment can fill it in.</summary>
public sealed class PlaywrightOptions
{
public bool? Enabled { get; set; }
public bool? Headless { get; set; }
public string? Browser { get; set; }
public List<string>? ExtraArgs { get; set; }
/// <summary>npm package spec used with npx. Defaults to <see cref="PlaywrightPreset.DefaultPackage"/>.</summary>
public string? Package { get; set; }
/// <summary>Run this binary instead of <c>npx -y &lt;package&gt;</c> (e.g. the globally installed <c>playwright-mcp</c>).</summary>
public string? Command { get; set; }
public bool IsEnabled => Enabled ?? false;
public bool IsHeadless => Headless ?? true;
}

View File

@@ -0,0 +1,103 @@
using System.ComponentModel;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>A started server: the SDK client over a stdio transport. Used for discovery and for session processes.</summary>
internal sealed class McpProcess : IAsyncDisposable
{
private static readonly TimeSpan StderrGrace = TimeSpan.FromMilliseconds(300);
private int _disposed;
private readonly DateTime _spawnedAfter;
private readonly DateTime _spawnedBefore;
private McpProcess(McpClient client, DateTime spawnedAfter, DateTime spawnedBefore)
{
Client = client;
_spawnedAfter = spawnedAfter;
_spawnedBefore = spawnedBefore;
}
public McpClient Client { get; }
public Task<ClientCompletionDetails> Completion => Client.Completion;
public bool HasExited => Client.Completion.IsCompleted;
/// <summary>
/// Spawns the prepared process and runs the handshake under <c>startupTimeoutSeconds</c>. Stderr goes to
/// <paramref name="stderr"/>, which redacts it, and then to the log.
/// </summary>
public static async Task<McpProcess> StartAsync(string name, McpServerConfig config, ProcessSpawnContext spawn, StderrTail stderr, ILogger logger, CancellationToken cancellationToken)
{
var timeout = TimeSpan.FromSeconds(config.StartupTimeoutSeconds);
var transport = new StdioClientTransport(McpLaunch.TransportOptions(name, spawn, line =>
logger.Log(config.StderrLevel, "{Server} mcp: {Line}", name, stderr.Add(line))));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
var spawnedAfter = DateTime.Now;
try
{
var client = await McpClient.CreateAsync(
transport,
new McpClientOptions { ClientInfo = new Implementation { Name = "volcanic-agentic-core", Version = "1.0.0" }, InitializationTimeout = timeout },
loggerFactory: null,
cts.Token).ConfigureAwait(false);
return new McpProcess(client, spawnedAfter, DateTime.Now);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new McpServerStartException($"{name} MCP server did not start within {config.StartupTimeoutSeconds}s{TailSuffix(stderr)}");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await Task.Delay(StderrGrace, CancellationToken.None).ConfigureAwait(false); // let the dying process's last lines arrive
throw new McpServerStartException($"{name} MCP server failed to start: {ex.Message}{TailSuffix(stderr)}", ex);
}
}
public static string TailSuffix(StderrTail stderr) =>
stderr.Lines is { Count: > 0 } lines ? "\n" + string.Join('\n', lines) : "";
/// <summary>The completion details if the process ends within <paramref name="grace"/>, else null.</summary>
public async Task<ClientCompletionDetails?> WaitForExitAsync(TimeSpan grace)
{
var finished = await Task.WhenAny(Completion, Task.Delay(grace)).ConfigureAwait(false);
return finished == Completion ? await Completion.ConfigureAwait(false) : null;
}
/// <summary>
/// Idempotent. The SDK (2.2) waits up to <c>ShutdownTimeout</c> for the process to exit, with stdin still open, then
/// kills its tree; so a live server always costs that long. Only when the SDK reports no exit code (so the process
/// may have survived) is the tree killed here, and only if the process found under the pid started while this server
/// was being spawned: a reused pid belongs to some other process and is left alone.
/// </summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { await Client.DisposeAsync().ConfigureAwait(false); }
catch (Exception) { /* a dead transport may throw on dispose; the process is handled below */ }
if (Client.Completion.IsCompletedSuccessfully
&& Client.Completion.Result is StdioClientCompletionDetails { ProcessId: { } pid, ExitCode: null })
KillTreeIfOurs(pid);
}
private void KillTreeIfOurs(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
if (process.HasExited) return;
var started = process.StartTime;
if (started < _spawnedAfter.AddSeconds(-1) || started > _spawnedBefore.AddSeconds(1)) return;
process.Kill(entireProcessTree: true);
}
catch (ArgumentException) { }
catch (InvalidOperationException) { }
catch (Win32Exception) { }
catch (NotSupportedException) { }
}
}

View File

@@ -0,0 +1,98 @@
using System.Text;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using CoreBlock = Volcanic.AgenticCore.Abstractions.Messages.ContentBlock;
using McpBlock = ModelContextProtocol.Protocol.ContentBlock;
using TextContent = Volcanic.AgenticCore.Abstractions.Messages.TextContent;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Maps an MCP <c>tools/call</c> result to a core <see cref="ToolResult"/> (spec §7).</summary>
internal static class McpResultMapper
{
public const int MaxImageBytes = 5 * 1024 * 1024;
public const int MaxLines = 2000;
public const int MaxBytes = 50 * 1024;
/// <summary>
/// One <see cref="TextContent"/> (the joined, truncated text) followed by the images in order.
/// Throws <see cref="ToolExecutionException"/> carrying the mapped result when the server set <c>isError</c>.
/// </summary>
public static ToolResult Map(string server, string tool, CallToolResult result)
{
var text = new List<string>();
var images = new List<ImageContent>();
foreach (var block in result.Content ?? []) Add(block, text, images);
if (text.Count == 0 && images.Count == 0 && result.StructuredContent is { } structured)
text.Add(structured.GetRawText());
var (shown, truncated) = Truncate(string.Join('\n', text));
var content = new List<CoreBlock>();
if (shown.Length > 0) content.Add(new TextContent(shown));
else if (images.Count == 0) content.Add(new TextContent("(no output)"));
content.AddRange(images);
var isError = result.IsError == true;
var mapped = new ToolResult(content, VolcanicJson.ToElement(new { server, tool, isError, truncated, images = images.Count }));
if (isError) throw new ToolExecutionException(mapped);
return mapped;
}
private static void Add(McpBlock block, List<string> text, List<ImageContent> images)
{
switch (block)
{
case TextContentBlock t:
text.Add(t.Text);
break;
case ImageContentBlock image:
var size = image.DecodedData.Length;
if (size > MaxImageBytes) text.Add($"[image omitted: {image.MimeType}, {size} bytes]");
else images.Add(new ImageContent(Encoding.UTF8.GetString(image.Data.Span), image.MimeType));
break;
case AudioContentBlock audio:
text.Add($"[audio omitted: {audio.MimeType}, {audio.DecodedData.Length} bytes]");
break;
case EmbeddedResourceBlock { Resource: TextResourceContents resource }:
text.Add($"Resource {resource.Uri}:\n{resource.Text}");
break;
case EmbeddedResourceBlock { Resource: BlobResourceContents blob }:
text.Add($"[resource {blob.Uri}: {blob.MimeType ?? "application/octet-stream"}, {blob.DecodedData.Length} bytes omitted]");
break;
case ResourceLinkBlock link:
text.Add($"Resource link: {link.Uri} ({link.Name})");
break;
default:
text.Add($"[{block.Type} content omitted]");
break;
}
}
/// <summary>
/// Head truncation with the core <c>OutputTruncation.Head</c> limits and note wording, reimplemented here because
/// that type lives in the runtime. The note follows the kept text on its own line, as in the built-in tools.
/// </summary>
private static (string Text, bool Truncated) Truncate(string text)
{
var lines = text.Split('\n');
var totalBytes = Encoding.UTF8.GetByteCount(text);
if (lines.Length <= MaxLines && totalBytes <= MaxBytes) return (text, false);
var sb = new StringBuilder();
var bytes = 0;
var shown = 0;
foreach (var line in lines)
{
var lineBytes = Encoding.UTF8.GetByteCount(line) + 1;
if (shown >= MaxLines || bytes + lineBytes > MaxBytes) break;
if (shown > 0) sb.Append('\n');
sb.Append(line);
bytes += lineBytes;
shown++;
}
var note = $"[Output truncated: showing {shown} of {lines.Length} lines ({totalBytes} bytes). Use offset/limit or a narrower query to see more.]";
return ($"{sb}\n{note}", true);
}
}

View File

@@ -0,0 +1,280 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
internal enum McpConnectionStatus { NotStarted, Running, Failed }
/// <summary>One server process for one session: started on the first call, stopped on session shutdown or when idle.</summary>
internal sealed class McpServerConnection : IAsyncDisposable
{
private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(2);
private static readonly TimeSpan StderrGrace = TimeSpan.FromMilliseconds(150);
private static readonly TimeSpan CatalogCheckCap = TimeSpan.FromSeconds(10);
private readonly McpServerConfig _config;
private readonly ILogger _logger;
private readonly Func<string, string?> _getEnv;
private readonly McpCatalog? _catalog;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Func<string, string> _redact;
private readonly StderrTail _stderr;
private readonly Lock _lock = new();
private McpProcess? _process;
private ITimer? _idleTimer;
private long _timerGeneration;
private int _inFlight;
private int _disposed;
private long _nextRequestId;
/// <param name="redact">Applied to the server's stderr as it arrives and to logged errors; identity when null.</param>
public McpServerConnection(string name, McpServerConfig config, ILogger logger, Func<string, string?> getEnv, McpCatalog? catalog = null, TimeProvider? time = null, Func<string, string>? redact = null)
{
Name = name;
_redact = redact ?? (static text => text);
_stderr = new StderrTail(redact);
_config = config;
_logger = logger;
_getEnv = getEnv;
_catalog = catalog;
_time = time ?? TimeProvider.System;
}
public string Name { get; }
public McpConnectionStatus Status { get; private set; }
public string? LastError { get; private set; }
public int StartCount { get; private set; }
public IReadOnlyList<string> StderrTail => _stderr.Lines;
/// <summary>True when the current process has ended but no call has noticed yet (the status still says running).</summary>
public bool ProcessExited => Volatile.Read(ref _process)?.HasExited == true;
private bool Disposed => Volatile.Read(ref _disposed) == 1;
/// <summary>
/// Calls <paramref name="mcpName"/> on the session's process, starting it first if needed. Throws
/// <see cref="TimeoutException"/>, <see cref="OperationCanceledException"/>, <see cref="McpServerExitedException"/>
/// or <see cref="McpServerStartException"/>; a JSON-RPC error from the server surfaces as the SDK's exception.
/// </summary>
public async Task<CallToolResult> CallAsync(string mcpName, JsonElement arguments, IExtensionContext context, CancellationToken cancellationToken)
{
var process = await EnsureStartedAsync(context, cancellationToken).ConfigureAwait(false);
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(_config.CallTimeoutSeconds));
var request = new CallToolRequestParams { Name = mcpName, Arguments = ToArguments(arguments) };
var id = new RequestId($"volcanic-{Interlocked.Increment(ref _nextRequestId).ToString(CultureInfo.InvariantCulture)}");
try
{
return await process.Client.SendRequestAsync<CallToolRequestParams, CallToolResult>(
RequestMethods.ToolsCall, request, McpJsonUtilities.DefaultOptions, id, timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
await SendCancelledAsync(process, id, "aborted").ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
{
await SendCancelledAsync(process, id, "timed out").ConfigureAwait(false);
throw new TimeoutException($"{mcpName} timed out after {_config.CallTimeoutSeconds}s");
}
catch (Exception ex) when (ex is not McpProtocolException)
{
if (await process.WaitForExitAsync(ExitGrace).ConfigureAwait(false) is { } exit)
throw await OnExitedAsync(process, exit).ConfigureAwait(false);
throw;
}
}
finally
{
EndCall();
}
}
/// <summary>Stops the process; the next call starts a new one. Idempotent.</summary>
public async ValueTask StopAsync()
{
await _gate.WaitAsync().ConfigureAwait(false);
try { await StopCoreAsync().ConfigureAwait(false); }
finally { _gate.Release(); }
}
/// <summary>Stops the process and refuses later calls with <see cref="ObjectDisposedException"/>. Idempotent.</summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
await StopAsync().ConfigureAwait(false);
}
/// <summary>Caller holds <see cref="_gate"/>. Resets a failed status too; <see cref="LastError"/> stays for <c>/mcp &lt;name&gt;</c>.</summary>
private async ValueTask StopCoreAsync()
{
lock (_lock) { _idleTimer?.Dispose(); _idleTimer = null; _timerGeneration++; }
if (Interlocked.Exchange(ref _process, null) is { } process) await process.DisposeAsync().ConfigureAwait(false);
Status = McpConnectionStatus.NotStarted;
}
/// <summary>Returns a live process with the call counted as in flight (so the idle timer can't stop it underneath).</summary>
private async Task<McpProcess> EnsureStartedAsync(IExtensionContext context, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(Disposed, this);
if (_process is { } existing)
{
if (existing.HasExited) throw await OnExitedAsync(existing, await existing.Completion.ConfigureAwait(false)).ConfigureAwait(false);
BeginCall();
return existing;
}
ProcessSpawnContext spawn;
try
{
spawn = await context.PrepareProcessAsync(McpLaunch.BuildRequest(Name, _config, context.Cwd, _getEnv), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw Fail(new McpServerStartException($"{Name}: process environment hook failed: {ex.Message}", ex));
}
_stderr.Clear();
McpProcess process;
try { process = await McpProcess.StartAsync(Name, _config, spawn, _stderr, _logger, cancellationToken).ConfigureAwait(false); }
catch (McpServerStartException ex) { throw Fail(ex); }
_process = process;
StartCount++;
Status = McpConnectionStatus.Running;
LastError = null;
_logger.LogInformation("MCP server {Name} started in {Cwd}", Name, spawn.Cwd);
_ = Task.Run(() => WarnIfCatalogChangedAsync(process), CancellationToken.None); // only logs; never holds the gate
BeginCall();
return process;
}
finally
{
_gate.Release();
}
}
private McpServerStartException Fail(McpServerStartException ex)
{
Status = McpConnectionStatus.Failed;
LastError = ex.Message;
_logger.LogWarning("{Message}", _redact(ex.Message));
return ex;
}
private async Task<McpServerExitedException> OnExitedAsync(McpProcess process, ClientCompletionDetails? details)
{
Interlocked.CompareExchange(ref _process, null, process);
await process.DisposeAsync().ConfigureAwait(false);
await Task.Delay(StderrGrace).ConfigureAwait(false); // the last stderr lines race the exit
var code = (details as StdioClientCompletionDetails)?.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "?";
var tail = _stderr.Lines is { Count: > 0 } lines ? string.Join('\n', lines) : "no stderr output";
var message = $"{Name} MCP server exited ({code}): {tail}. It restarts on the next call; browser state was lost.";
Status = McpConnectionStatus.Failed;
LastError = message;
_logger.LogWarning("{Message}", message);
return new McpServerExitedException(message);
}
private void BeginCall()
{
lock (_lock)
{
_inFlight++;
_timerGeneration++;
_idleTimer?.Dispose();
_idleTimer = null;
}
}
private void EndCall()
{
lock (_lock)
{
if (--_inFlight > 0 || _config.IdleTimeoutSeconds == 0 || Disposed) return;
_idleTimer?.Dispose();
var generation = ++_timerGeneration;
_idleTimer = _time.CreateTimer(_ => _ = StopIfIdleAsync(generation), null, TimeSpan.FromSeconds(_config.IdleTimeoutSeconds), Timeout.InfiniteTimeSpan);
}
}
/// <summary>A callback that fired before a later call reset the timer finds a newer generation and does nothing.</summary>
private async Task StopIfIdleAsync(long generation)
{
try
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
lock (_lock) { if (_inFlight > 0 || _timerGeneration != generation) return; }
if (_process is null) return;
_logger.LogInformation("MCP server {Name} was idle for {Seconds}s; stopping it", Name, _config.IdleTimeoutSeconds);
await StopCoreAsync().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
_logger.LogWarning("MCP server {Name}: idle stop failed: {Message}", Name, ex.Message);
}
}
private async Task WarnIfCatalogChangedAsync(McpProcess process)
{
if (_catalog is null) return;
try
{
var cap = TimeSpan.FromSeconds(_config.StartupTimeoutSeconds);
using var cts = new CancellationTokenSource(cap < CatalogCheckCap ? cap : CatalogCheckCap);
var live = await process.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false);
var liveNames = live.Select(t => t.Name).Order(StringComparer.Ordinal);
if (!liveNames.SequenceEqual(_catalog.Tools.Select(t => t.McpName).Order(StringComparer.Ordinal), StringComparer.Ordinal))
_logger.LogWarning("MCP server {Name}: its tools changed since discovery; calls still go by name. Restart the host to pick up the new list.", Name);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
_logger.LogDebug("MCP server {Name}: could not compare its tools with the catalogue: {Message}", Name, ex.Message);
}
}
/// <summary>
/// The SDK (2.2.0) doesn't send <c>notifications/cancelled</c> when a request's token fires (checked against the fake
/// server), so the call carries its own id and the notification goes out here. Best effort: a dead server can't take it.
/// </summary>
private async Task SendCancelledAsync(McpProcess process, RequestId id, string reason)
{
try
{
using var cts = new CancellationTokenSource(ExitGrace);
await process.Client.SendNotificationAsync(NotificationMethods.CancelledNotification,
new CancelledNotificationParams { RequestId = id, Reason = reason }, McpJsonUtilities.DefaultOptions, cts.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
_logger.LogDebug("MCP server {Name}: could not send notifications/cancelled: {Message}", Name, ex.Message);
}
}
private static Dictionary<string, JsonElement> ToArguments(JsonElement arguments)
{
var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
if (arguments.ValueKind == JsonValueKind.Object)
foreach (var property in arguments.EnumerateObject()) result[property.Name] = property.Value.Clone();
return result;
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>One configured server as a session sees it: config, prompt section and whether it can run.</summary>
internal sealed record McpServerEntry
{
public required string Name { get; init; }
public required McpServerConfig Config { get; init; }
/// <summary>Key in <c>SystemPromptOptions.Sections</c>.</summary>
public required string SectionKey { get; init; }
/// <summary>The prompt section given whether a <c>send</c> tool exists; null for none.</summary>
public required Func<bool, string?> PromptSection { get; init; }
public bool Disabled { get; init; }
public string? DisabledHint { get; init; }
/// <summary>Validation errors; a server with a problem is listed but never started.</summary>
public string? Problem { get; init; }
public static McpServerEntry For(string name, McpServerConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var errors = config.Validate(name);
return new()
{
Name = name,
Config = config,
SectionKey = name,
PromptSection = _ => config.PromptSection,
Disabled = !config.Enabled,
Problem = errors.Count == 0 ? null : string.Join(" ", errors),
};
}
public static McpServerEntry Preset(PlaywrightOptions options) => new()
{
Name = PlaywrightPreset.ServerName,
Config = PlaywrightPreset.ToServer(options),
SectionKey = PlaywrightPreset.SectionKey,
PromptSection = hasSend => PlaywrightPreset.PromptSection(hasSend),
};
public static McpServerEntry DisabledPreset() => new()
{
Name = PlaywrightPreset.ServerName,
Config = new McpServerConfig(),
SectionKey = PlaywrightPreset.SectionKey,
PromptSection = _ => null,
Disabled = true,
DisabledHint = "set PLAYWRIGHT_ENABLED=true",
};
}
/// <summary>Merges the configuration sources. The first source wins a server name.</summary>
internal static class McpServerSet
{
/// <summary>
/// Host servers by name, then the Playwright preset when enabled, then the user settings in file order. A taken
/// name is skipped with a log line. A disabled preset is listed last, only while <c>playwright</c> is still free.
/// Host servers are sorted because configuration binding sorts keys anyway (<c>ConfigurationKeyComparer</c>), so
/// appsettings order can't be kept; sorting here gives options built in code the same rule.
/// </summary>
public static IReadOnlyList<McpServerEntry> Resolve(McpOptions options, IEnumerable<KeyValuePair<string, McpServerConfig>> userServers, ILogger logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(userServers);
ArgumentNullException.ThrowIfNull(logger);
var entries = new List<McpServerEntry>();
var names = new HashSet<string>(StringComparer.Ordinal);
var hostServers = options.Servers
.OrderBy(s => s.Key, StringComparer.OrdinalIgnoreCase)
.ThenBy(s => s.Key, StringComparer.Ordinal);
foreach (var (name, config) in hostServers)
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
if (options.Playwright.IsEnabled)
{
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.Preset(options.Playwright));
else logger.LogWarning("MCP server 'playwright' is configured explicitly; the Playwright preset is ignored.");
}
foreach (var (name, config) in userServers)
{
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
else logger.LogInformation("MCP server '{Name}' in the user settings is ignored: the host configuration defines it.", name);
}
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.DisabledPreset());
return entries;
}
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>An MCP server tool as an agent tool. The schema passes through; the server validates what the core doesn't.</summary>
internal sealed class McpTool : ITool
{
private static readonly JsonElement ObjectSchema = JsonSerializer.SerializeToElement(new { type = "object" });
private readonly McpCatalogTool _tool;
private readonly McpServerConnection _connection;
private readonly bool _parallel;
public McpTool(McpCatalogTool tool, McpServerConnection connection, bool parallel)
{
_tool = tool;
_connection = connection;
_parallel = parallel;
Description = !string.IsNullOrWhiteSpace(tool.Description) ? tool.Description!
: !string.IsNullOrWhiteSpace(tool.Title) ? tool.Title!
: tool.McpName;
Parameters = NormalizeSchema(tool.InputSchema);
}
public string Name => _tool.ExposedName;
public string Description { get; }
public JsonElement Parameters { get; }
public string? PromptSnippet => null;
public ToolExecutionMode ExecutionMode => _parallel ? ToolExecutionMode.Parallel : ToolExecutionMode.Sequential;
public async Task<ToolResult> ExecuteAsync(ToolCallContent call, ToolInvocation invocation, CancellationToken cancellationToken)
{
var result = await _connection.CallAsync(_tool.McpName, call.Arguments, invocation.Context, cancellationToken).ConfigureAwait(false);
return McpResultMapper.Map(_connection.Name, _tool.McpName, result);
}
/// <summary>A missing or non-object schema becomes <c>{"type":"object"}</c>; a top-level <c>$schema</c> is dropped.</summary>
internal static JsonElement NormalizeSchema(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object) return ObjectSchema;
if (!schema.TryGetProperty("$schema", out _)) return schema.Clone();
var node = JsonNode.Parse(schema.GetRawText())!.AsObject();
node.Remove("$schema");
return JsonSerializer.SerializeToElement(node);
}
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Reads <c>"mcpServers"</c> from <c>&lt;agent dir&gt;/settings.json</c> (the same file that lists extensions).</summary>
internal static class McpUserSettings
{
public const string AgentDirVariable = "VOLCANIC_AGENT_DIR";
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary><c>VOLCANIC_AGENT_DIR</c>, else <c>~/.volcanic/agent</c>.</summary>
public static string AgentDir(Func<string, string?> getEnv) =>
getEnv(AgentDirVariable) is { Length: > 0 } dir
? dir
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent");
/// <summary>Servers in document order. A missing file or section is empty; a malformed file is logged and ignored.</summary>
public static IReadOnlyList<KeyValuePair<string, McpServerConfig>> Load(string agentDir, ILogger logger)
{
var path = Path.Combine(agentDir, "settings.json");
if (!File.Exists(path)) return [];
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("mcpServers", out var servers)
|| servers.ValueKind != JsonValueKind.Object) return [];
var result = new List<KeyValuePair<string, McpServerConfig>>();
foreach (var server in servers.EnumerateObject())
result.Add(new(server.Name, server.Value.Deserialize<McpServerConfig>(Json) ?? new McpServerConfig()));
return result;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
{
logger.LogWarning("Ignoring mcpServers in {Path}: {Message}", path, ex.Message);
return [];
}
}
}

View File

@@ -0,0 +1,90 @@
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>The <c>@playwright/mcp</c> preset: a server named <c>playwright</c> whose output and profile live in the chat workspace.</summary>
public static class PlaywrightPreset
{
public const string ServerName = "playwright";
/// <summary>The single source of truth for the version. The Dockerfile's <c>PLAYWRIGHT_MCP_VERSION</c> must match it.</summary>
public const string DefaultPackage = "@playwright/mcp@0.0.82";
/// <summary>Without <c>--browser</c>, @playwright/mcp uses the Chrome channel, which the deployment doesn't install.</summary>
public const string DefaultBrowser = "chromium";
/// <summary>Key of the preset's section in the system prompt.</summary>
public const string SectionKey = "browser";
/// <summary>The exact version in <see cref="DefaultPackage"/>.</summary>
public static string PackageVersion => DefaultPackage[(DefaultPackage.LastIndexOf('@') + 1)..];
/// <summary>The server entry the preset expands into.</summary>
public static McpServerConfig ToServer(PlaywrightOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var args = new List<string>();
var command = options.Command;
if (string.IsNullOrWhiteSpace(command))
{
command = "npx";
args.Add("-y");
args.Add(string.IsNullOrWhiteSpace(options.Package) ? DefaultPackage : options.Package);
}
if (options.IsHeadless) args.Add("--headless");
args.Add("--browser");
args.Add(Browser(options));
args.Add("--output-dir");
args.Add("${workspace}/browser");
var extra = options.ExtraArgs ?? [];
if (!extra.Contains("--isolated"))
{
args.Add("--user-data-dir");
args.Add("${workspace}/.volcanic/browser-profile");
}
args.AddRange(extra);
return new McpServerConfig
{
Command = command,
Args = args,
Cwd = "${workspace}",
StartupTimeoutSeconds = 120,
CallTimeoutSeconds = 120,
};
}
/// <summary>The startup log line: whether the preset is on, and with which mode and browser.</summary>
public static string Describe(PlaywrightOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return options.IsEnabled
? $"MCP: playwright enabled ({(options.IsHeadless ? "headless" : "headed")}, {Browser(options)})"
: "MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)";
}
/// <summary>The <c>browser</c> prompt section. The <c>send</c> line appears only when a <c>send</c> tool is registered.</summary>
public static string PromptSection(bool hasSend)
{
string[] files = hasSend
?
[
"- To give the user a screenshot, save it with `filename: \"browser/<name>.png\"` and then call `send` with that",
" path. Files the browser saves (PDFs, downloads) are under browser/ too.",
]
:
[
"- Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are",
" under browser/ in the workspace.",
];
string[] lines =
[
"## Browser",
"- browser_* tools drive a real browser. Use browser_snapshot (an accessibility tree with element refs) to read a",
" page and find refs for click/type; it is cheaper and more reliable than screenshots.",
"- Take a screenshot without `filename` only when you need to see the visual layout yourself; the image comes back",
" to you.",
.. files,
"- Close the browser (browser_close) when a task is finished. Browser state may be reset after a long pause.",
"- Don't enter passwords or payment details unless the user explicitly asks for that specific site.",
];
return string.Join('\n', lines);
}
private static string Browser(PlaywrightOptions options) =>
string.IsNullOrWhiteSpace(options.Browser) ? DefaultBrowser : options.Browser;
}

View File

@@ -0,0 +1,43 @@
using System.Text;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Splits a command-line fragment into argv the way a POSIX shell would, without expansion.</summary>
internal static class ShellWords
{
/// <summary>Whitespace separates words; '…' is literal; "…" allows \" and \\. Backslashes elsewhere are kept (Windows paths).</summary>
public static IReadOnlyList<string> Split(string text)
{
var words = new List<string>();
var current = new StringBuilder();
var inWord = false;
char? quote = null;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (quote == '\'')
{
if (c == '\'') quote = null; else current.Append(c);
continue;
}
if (quote == '"')
{
if (c == '"') quote = null;
else if (c == '\\' && i + 1 < text.Length && text[i + 1] is '"' or '\\') current.Append(text[++i]);
else current.Append(c);
continue;
}
if (char.IsWhiteSpace(c))
{
if (inWord) { words.Add(current.ToString()); current.Clear(); inWord = false; }
continue;
}
inWord = true;
if (c is '"' or '\'') quote = c;
else current.Append(c);
}
if (quote is { } open) throw new FormatException($"unterminated {open} quote");
if (inWord) words.Add(current.ToString());
return words;
}
}

View File

@@ -0,0 +1,27 @@
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// The last lines a server wrote to stderr: a server that dies explains why only there. Lines are redacted as they
/// arrive, so the log, error messages and <c>/mcp</c> built from them never hold a known secret.
/// </summary>
internal sealed class StderrTail(Func<string, string>? redact = null, int capacity = 20)
{
private readonly Queue<string> _lines = new();
private readonly Lock _lock = new();
/// <summary>Stores the redacted line and returns it.</summary>
public string Add(string line)
{
if (redact is not null) line = redact(line);
lock (_lock)
{
_lines.Enqueue(line);
while (_lines.Count > capacity) _lines.Dequeue();
}
return line;
}
public void Clear() { lock (_lock) _lines.Clear(); }
public IReadOnlyList<string> Lines { get { lock (_lock) return [.. _lines]; } }
}

View File

@@ -0,0 +1,40 @@
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// Exposed tool names: <c>prefix + mcpName</c>, characters outside <c>[A-Za-z0-9_-]</c> replaced by <c>_</c>, at most 48
/// characters so the Claude Code provider's <c>ToolCatalog</c> keeps them readable, duplicates suffixed <c>_2</c>, <c>_3</c>.
/// </summary>
internal static partial class ToolNaming
{
public const int MaxLength = 48;
public static string Sanitize(string? prefix, string mcpName)
{
var name = InvalidCharacters().Replace((prefix ?? "") + mcpName, "_");
if (name.Length == 0) name = "tool";
return name.Length > MaxLength ? name[..MaxLength] : name;
}
public static IReadOnlyList<string> Expose(IReadOnlyList<string> mcpNames, string? prefix)
{
var used = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>(mcpNames.Count);
foreach (var mcpName in mcpNames)
{
var baseName = Sanitize(prefix, mcpName);
var name = baseName;
for (var n = 2; !used.Add(name); n++)
{
var suffix = $"_{n}";
name = (baseName.Length + suffix.Length > MaxLength ? baseName[..(MaxLength - suffix.Length)] : baseName) + suffix;
}
result.Add(name);
}
return result;
}
[GeneratedRegex("[^A-Za-z0-9_-]")]
private static partial Regex InvalidCharacters();
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
<PackageReference Include="ModelContextProtocol.Core" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
</ItemGroup>
</Project>

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram;
@@ -24,7 +25,9 @@ options.ApplyEnvironment(Environment.GetEnvironmentVariable);
var processSection = builder.Configuration.GetSection(ProcessEnvironmentSection.SectionName).Get<ProcessEnvironmentSection>() ?? new ProcessEnvironmentSection();
var processEnvironment = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable);
var errors = options.Validate().Concat(processEnvironment.Validate()).ToList();
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).ToList();
if (errors.Count > 0)
{
foreach (var error in errors) Console.Error.WriteLine(error);
@@ -32,6 +35,7 @@ if (errors.Count > 0)
}
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(mcp);
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
// OwnerLoginChannel lazily (at call time, not here) to avoid a construction cycle: ChatRegistry depends on the
@@ -45,6 +49,7 @@ builder.Services.AddAgenticCore(o =>
o.DefaultModel = options.Model;
o.ProcessEnvironment = processEnvironment;
o.AddExtension<ClaudeCodeProviderExtension>();
o.AddExtension<McpExtension>();
});
builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(options.BotToken));
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
@@ -64,6 +69,7 @@ var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Vo
// Before polling and the first claude process; the spawn policy's own call later is a no-op.
HostProcessHardening.Apply(processEnvironment, logger);
logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment));
logger.LogInformation("{Mcp}", mcp.DescribePlaywright());
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else

View File

@@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />
</ItemGroup>

View File

@@ -95,3 +95,9 @@ public sealed class DelegateTool<TArgs> : Tool<TArgs>
protected override Task<ToolResult> ExecuteAsync(TArgs args, ToolInvocation invocation, CancellationToken cancellationToken) =>
_execute(args, invocation, cancellationToken);
}
/// <summary>Thrown by tools that want to return content and details while still marking the result as an error.</summary>
public sealed class ToolExecutionException(ToolResult result) : Exception(result.TextContent)
{
public ToolResult Result { get; } = result;
}

View File

@@ -5,7 +5,6 @@ using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Features.Tools.BuiltIn;
namespace Volcanic.AgenticCore.Features.Agent;

View File

@@ -108,8 +108,3 @@ public sealed class BashTool : Tool<BashArgs>
}
}
/// <summary>Thrown by tools that want to return content and details while still marking the result as an error.</summary>
public sealed class ToolExecutionException(ToolResult result) : Exception(result.TextContent)
{
public ToolResult Result { get; } = result;
}

View File

@@ -0,0 +1,153 @@
using System.Collections;
using System.Text;
using System.Text.Json;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
/// <summary>Test MCP stdio server. <c>FAKE_MCP_MODE</c> (comma-separated) selects behaviour; <c>FAKE_MCP_LOG</c> receives JSON lines.</summary>
public static class FakeServerProgram
{
public const string LongToolName = "a_tool_whose_name_is_much_longer_than_forty_eight_characters";
public const string Png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
public const string EchoSchema = """{"type":"object","properties":{"text":{"type":"string","description":"Text to echo"}},"required":["text"]}""";
public const int DieExitCode = 3;
public static async Task<int> Main(string[] args)
{
var modes = (Environment.GetEnvironmentVariable("FAKE_MCP_MODE") is { Length: > 0 } m ? m : "normal")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.Ordinal);
var log = Environment.GetEnvironmentVariable("FAKE_MCP_LOG");
if (modes.Contains("count-starts"))
Log(log, new { @event = "start", pid = Environment.ProcessId, cwd = Directory.GetCurrentDirectory(), args, env = modes.Contains("env") ? Snapshot() : null });
if (modes.Contains("crash-on-start"))
{
await Console.Error.WriteLineAsync("Traceback (most recent call last):");
await Console.Error.WriteLineAsync(" File \"fake_server.py\", line 1, in <module>");
await Console.Error.WriteLineAsync("RuntimeError: fake crash on start");
return 1;
}
if (modes.Contains("hang-on-start"))
{
await Console.In.ReadToEndAsync(); // swallow initialize, never answer; exit once the client closes stdin
return 0;
}
var playwrightLike = args.Contains("--output-dir");
var calls = 0;
var options = new McpServerOptions
{
ServerInfo = new Implementation { Name = "fake-mcp", Version = "1.2.3" },
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
Handlers = new McpServerHandlers
{
ListToolsHandler = (_, _) => ValueTask.FromResult(new ListToolsResult { Tools = Tools(modes, playwrightLike) }),
CallToolHandler = async (request, ct) =>
{
var result = await CallAsync(request.Params!, log, ct);
if (modes.Contains("die-after-first-call") && Interlocked.Increment(ref calls) == 1) _ = DieSoonAsync();
return result;
},
},
};
await using var server = McpServer.Create(new StdioServerTransport(options), options);
await server.RunAsync();
return 0;
}
private static List<Tool> Tools(HashSet<string> modes, bool playwrightLike)
{
var tools = new List<Tool>
{
Make("echo", "Echoes text back", EchoSchema),
Make("image", "Returns a 1x1 PNG"),
Make("fail", "Always fails"),
Make("slow", "Waits until cancelled"),
Make("weird.name/x", "Has an awkward name"),
Make("weird_name_x", "Collides with weird.name/x after sanitising"),
new() { Name = "noschema", Description = "Has no input schema" },
Make(LongToolName, "Has a long name"),
};
if (modes.Contains("env")) tools.Add(Make("env", "Returns the server's environment"));
if (modes.Contains("builtin-clash")) tools.Add(Make("read", "Clashes with the built-in read tool"));
if (playwrightLike)
{
tools.Add(Make("browser_navigate", "Navigate to a URL", """{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}"""));
tools.Add(Make("browser_take_screenshot", "Take a screenshot", """{"type":"object","properties":{"filename":{"type":"string"}}}"""));
}
return tools;
}
private static Tool Make(string name, string description, string schema = """{"type":"object","properties":{}}""") =>
new() { Name = name, Description = description, InputSchema = JsonSerializer.Deserialize<JsonElement>(schema) };
private static async ValueTask<CallToolResult> CallAsync(CallToolRequestParams call, string? log, CancellationToken ct)
{
string Arg(string name) =>
call.Arguments is { } a && a.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";
switch (call.Name)
{
case "echo":
return Text($"echo: {Arg("text")}");
case "image":
return new CallToolResult { Content = [Image()] };
case "fail":
return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = "fake failure: nothing works" }, Image()] };
case "slow":
try { await Task.Delay(Timeout.Infinite, ct); }
catch (OperationCanceledException) { Log(log, new { @event = "cancelled", tool = "slow" }); throw; }
return Text("slow finished");
case "env":
return Text(string.Join('\n', Snapshot().OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => $"{kv.Key}={kv.Value}")));
case "browser_navigate":
return Text($"navigated to {Arg("url")}");
case "browser_take_screenshot":
var filename = Arg("filename");
if (filename.Length == 0) return new CallToolResult { Content = [Image()] };
var path = Path.GetFullPath(filename, Directory.GetCurrentDirectory());
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, Convert.FromBase64String(Png1x1), ct);
return Text($"saved {filename}");
default:
return Text($"called {call.Name}");
}
}
private static CallToolResult Text(string text) => new() { Content = [new TextContentBlock { Text = text }] };
private static ImageContentBlock Image() => new() { Data = Encoding.UTF8.GetBytes(Png1x1), MimeType = "image/png" };
private static Dictionary<string, string?> Snapshot()
{
var env = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (DictionaryEntry e in Environment.GetEnvironmentVariables()) env[(string)e.Key] = (string?)e.Value;
return env;
}
private static async Task DieSoonAsync()
{
await Task.Delay(200);
await Console.Error.WriteLineAsync($"dying: {Environment.GetEnvironmentVariable("FAKE_MCP_DIE_MESSAGE")}");
await Console.Error.FlushAsync();
Environment.Exit(DieExitCode);
}
private static void Log(string? path, object entry)
{
if (string.IsNullOrEmpty(path)) return;
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(entry) + "\n");
for (var attempt = 0; ; attempt++)
{
try
{
using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
stream.Write(bytes);
return;
}
catch (IOException) when (attempt < 20) { Thread.Sleep(25); }
}
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>false</IsTestProject>
<Description>Test helper: a stdio MCP server whose behaviour FAKE_MCP_MODE selects.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol.Core" Version="2.2.0" />
</ItemGroup>
</Project>

View File

@@ -114,4 +114,15 @@ public class ToolCatalogTests
var notObject = new ToolDescriptor("x", "d", Fixtures.Record("[]"));
Assert.Throws<ClaudeCodeException>(() => new ToolCatalog([notObject]));
}
[Fact]
public void Mcp_style_tool_names_alias_to_themselves()
{
var maxLength = new string('b', 48);
var catalog = new ToolCatalog([Tool("browser_navigate"), Tool("browser_take_screenshot"), Tool("weird_name_x_2"), Tool(maxLength)]);
Assert.Equal([maxLength, "browser_navigate", "browser_take_screenshot", "weird_name_x_2"], catalog.Aliases); // ordinal order
Assert.Contains("mcp__volcanic__browser_navigate", catalog.TransportNames);
Assert.DoesNotContain(catalog.Aliases, a => a.StartsWith("tool_", StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,95 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class FakeMcpServerSmokeTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-fake-mcp", Guid.NewGuid().ToString("N"));
private string LogPath => Path.Combine(_dir, "fake.log");
public FakeMcpServerSmokeTests() => Directory.CreateDirectory(_dir);
public void Dispose() { try { Directory.Delete(_dir, true); } catch (IOException) { } }
private async Task<McpClient> ConnectAsync(string mode, List<string>? stderr = null, params string[] extraArgs)
{
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "fake",
Command = "dotnet",
Arguments = [FakeMcp.DllPath, .. extraArgs],
WorkingDirectory = _dir,
EnvironmentVariables = new Dictionary<string, string?> { ["FAKE_MCP_MODE"] = mode, ["FAKE_MCP_LOG"] = LogPath },
StandardErrorLines = line => { if (stderr is not null) lock (stderr) stderr.Add(line); },
// The SDK waits this long for the process to exit before it closes stdin, so the default 5 s is spent on every dispose.
ShutdownTimeout = TimeSpan.FromMilliseconds(200),
});
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
return await McpClient.CreateAsync(transport, cancellationToken: cts.Token);
}
[Fact]
public async Task Normal_mode_lists_the_expected_tools()
{
await using var client = await ConnectAsync("normal");
var tools = await client.ListToolsAsync();
Assert.Equal(["echo", "image", "fail", "slow", "weird.name/x", "weird_name_x", "noschema", FakeServerProgram.LongToolName],
tools.Select(t => t.Name));
Assert.Equal("fake-mcp", client.ServerInfo.Name);
}
[Fact]
public async Task Echo_image_and_fail_behave()
{
await using var client = await ConnectAsync("normal");
var echo = await client.CallToolAsync("echo", new Dictionary<string, object?> { ["text"] = "hi" });
Assert.Equal("echo: hi", Assert.IsType<TextContentBlock>(Assert.Single(echo.Content)).Text);
var image = await client.CallToolAsync("image", new Dictionary<string, object?>());
Assert.Equal("image/png", Assert.IsType<ImageContentBlock>(Assert.Single(image.Content)).MimeType);
var fail = await client.CallToolAsync("fail", new Dictionary<string, object?>());
Assert.True(fail.IsError);
}
[Fact]
public async Task Env_mode_adds_the_env_tool_and_count_starts_logs_the_start()
{
await using var client = await ConnectAsync("env,count-starts");
var tools = await client.ListToolsAsync();
Assert.Contains(tools, t => t.Name == "env");
var env = await client.CallToolAsync("env", new Dictionary<string, object?>());
Assert.Contains("FAKE_MCP_MODE=env,count-starts", Assert.IsType<TextContentBlock>(Assert.Single(env.Content)).Text, StringComparison.Ordinal);
var start = Assert.Single(FakeMcp.Starts(LogPath));
Assert.True(start.GetProperty("pid").GetInt32() > 0);
Assert.Equal(Path.GetFullPath(_dir), Path.GetFullPath(start.GetProperty("cwd").GetString()!));
Assert.Equal("env,count-starts", start.GetProperty("env").GetProperty("FAKE_MCP_MODE").GetString());
}
[Fact]
public async Task Crash_on_start_fails_the_handshake_with_the_traceback_on_stderr()
{
var stderr = new List<string>();
await Assert.ThrowsAnyAsync<Exception>(() => ConnectAsync("crash-on-start", stderr));
await FakeMcp.WaitForAsync(() => { lock (stderr) return stderr.Any(l => l.Contains("fake crash on start", StringComparison.Ordinal)); }, "the traceback");
lock (stderr) Assert.Contains(stderr, l => l.StartsWith("Traceback", StringComparison.Ordinal));
}
[Fact]
public async Task Output_dir_argument_adds_browser_tools()
{
await using var client = await ConnectAsync("normal", null, "--output-dir", Path.Combine(_dir, "browser"));
var tools = await client.ListToolsAsync();
Assert.Contains(tools, t => t.Name == "browser_navigate");
var saved = await client.CallToolAsync("browser_take_screenshot", new Dictionary<string, object?> { ["filename"] = "browser/shot.png" });
Assert.Equal("saved browser/shot.png", Assert.IsType<TextContentBlock>(Assert.Single(saved.Content)).Text);
Assert.True(File.Exists(Path.Combine(_dir, "browser", "shot.png")));
}
}

View File

@@ -0,0 +1,182 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpCatalogCacheTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-catalog", Guid.NewGuid().ToString("N"));
private readonly Dictionary<string, string?> _env = McpTestEnvironment.Snapshot();
private readonly FakeTimeProvider _time = new();
private readonly RecordingLogger _logger = new();
private string LogPath => Path.Combine(_dir, "fake.log");
public McpCatalogCacheTests() => Directory.CreateDirectory(_dir);
public void Dispose() { try { Directory.Delete(_dir, true); } catch (IOException) { } }
private Task<McpCatalog> Discover(McpCatalogCache cache, McpServerConfig config, IProcessSpawnPolicy? policy = null) =>
cache.GetAsync("fake", config, policy ?? McpTestEnvironment.Policy(_env), McpTestEnvironment.Lookup(_env), _logger, CancellationToken.None);
[Fact]
public async Task Discovery_lists_tools_with_names_descriptions_and_schemas_verbatim()
{
var catalog = await Discover(new McpCatalogCache(_time), FakeMcp.Server());
Assert.Equal("fake", catalog.ServerName);
Assert.Equal("fake-mcp", catalog.ServerInfoName);
Assert.Equal("1.2.3", catalog.ServerVersion);
var echo = catalog.Tools.Single(t => t.McpName == "echo");
Assert.Equal("echo", echo.ExposedName);
Assert.Equal("Echoes text back", echo.Description);
Assert.True(JsonElement.DeepEquals(JsonSerializer.Deserialize<JsonElement>(FakeServerProgram.EchoSchema), echo.InputSchema));
}
[Fact]
public async Task Discovery_sanitises_truncates_and_suffixes_names()
{
var catalog = await Discover(new McpCatalogCache(_time), FakeMcp.Server());
Assert.Equal("weird_name_x", catalog.Tools.Single(t => t.McpName == "weird.name/x").ExposedName);
Assert.Equal("weird_name_x_2", catalog.Tools.Single(t => t.McpName == "weird_name_x").ExposedName);
Assert.Equal(FakeServerProgram.LongToolName[..48], catalog.Tools.Single(t => t.McpName == FakeServerProgram.LongToolName).ExposedName);
}
[Fact]
public async Task Discovery_applies_the_prefix()
{
var catalog = await Discover(new McpCatalogCache(_time), FakeMcp.Server(configure: c => c.ToolPrefix = "fk_"));
Assert.Equal("fk_echo", catalog.Tools.Single(t => t.McpName == "echo").ExposedName);
}
[Fact]
public async Task Tool_without_input_schema_arrives_as_an_object_schema()
{
var catalog = await Discover(new McpCatalogCache(_time), FakeMcp.Server());
var schema = catalog.Tools.Single(t => t.McpName == "noschema").InputSchema;
Assert.Equal(JsonValueKind.Object, schema.ValueKind);
Assert.Equal("object", schema.GetProperty("type").GetString());
}
[Fact]
public async Task Concurrent_requests_share_one_discovery()
{
var cache = new McpCatalogCache(_time);
var config = FakeMcp.Server("count-starts", LogPath);
var catalogs = await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(() => Discover(cache, config))));
Assert.Single(FakeMcp.Starts(LogPath));
Assert.All(catalogs, c => Assert.Same(catalogs[0], c));
}
[Fact]
public async Task Success_is_cached_for_the_process_lifetime()
{
var cache = new McpCatalogCache(_time);
await Discover(cache, FakeMcp.Server("count-starts", LogPath));
_time.Advance(TimeSpan.FromDays(1));
await Discover(cache, FakeMcp.Server("count-starts", LogPath)); // an equal config, a new instance
Assert.Single(FakeMcp.Starts(LogPath));
}
[Fact]
public async Task Crash_on_start_is_cached_for_the_cooldown_with_the_stderr_traceback()
{
var cache = new McpCatalogCache(_time);
var config = FakeMcp.Server("crash-on-start,count-starts", LogPath);
var first = await Assert.ThrowsAsync<McpServerStartException>(() => Discover(cache, config));
Assert.Contains("Traceback (most recent call last):", first.Message, StringComparison.Ordinal);
Assert.Contains("RuntimeError: fake crash on start", first.Message, StringComparison.Ordinal);
_time.Advance(McpCatalogCache.FailureCooldown - TimeSpan.FromSeconds(1));
var second = await Assert.ThrowsAsync<McpServerStartException>(() => Discover(cache, config));
Assert.Equal(first.Message, second.Message);
Assert.Single(FakeMcp.Starts(LogPath));
Assert.True(_logger.Has(LogLevel.Warning, "fake"));
}
[Fact]
public async Task Failure_is_retried_after_the_cooldown()
{
var cache = new McpCatalogCache(_time);
var config = FakeMcp.Server("crash-on-start,count-starts", LogPath);
await Assert.ThrowsAsync<McpServerStartException>(() => Discover(cache, config));
_time.Advance(McpCatalogCache.FailureCooldown);
await Assert.ThrowsAsync<McpServerStartException>(() => Discover(cache, config));
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
}
[Fact]
public async Task Hang_on_start_gives_up_after_the_startup_timeout()
{
// 3 s, not 1: the fake logs its start only after the .NET runtime is up, which can take over a second while
// the whole suite runs in parallel. A shorter timeout kills it before it logs and the Single below fails.
var config = FakeMcp.Server("hang-on-start,count-starts", LogPath, c => c.StartupTimeoutSeconds = 3);
var watch = Stopwatch.StartNew();
var ex = await Assert.ThrowsAsync<McpServerStartException>(() => Discover(new McpCatalogCache(_time), config));
Assert.Contains("did not start within 3s", ex.Message, StringComparison.Ordinal);
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(15), $"took {watch.Elapsed}");
var pid = Assert.Single(FakeMcp.Starts(LogPath)).GetProperty("pid").GetInt32();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the hung server to be stopped");
}
[Fact]
public async Task Discovery_runs_in_a_temp_directory_that_is_deleted_and_its_process_stops()
{
await Discover(new McpCatalogCache(_time), FakeMcp.Server("count-starts", LogPath));
var start = Assert.Single(FakeMcp.Starts(LogPath));
var cwd = start.GetProperty("cwd").GetString()!;
Assert.Contains("volcanic-mcp-discovery", cwd, StringComparison.Ordinal);
var pid = start.GetProperty("pid").GetInt32();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the discovery process to exit");
await FakeMcp.WaitForAsync(() => !Directory.Exists(cwd), "the discovery directory to be deleted");
}
[Fact]
public async Task Discovery_returns_without_waiting_for_the_server_shutdown()
{
// SDK 2.2 waits ShutdownTimeout with stdin still open before it kills the process, so an awaited dispose
// would hold every first activation for that long.
var watch = Stopwatch.StartNew();
await Discover(new McpCatalogCache(_time), FakeMcp.Server("count-starts", LogPath));
Assert.True(watch.Elapsed < McpLaunch.ShutdownTimeout, $"took {watch.Elapsed}");
}
[Fact]
public async Task Discovery_prepares_the_environment_through_the_policy()
{
ProcessSpawnContext? seen = null;
var policy = McpTestEnvironment.Policy(_env, o => o.Hooks.Add(c =>
{
seen = c;
return c with { Environment = new Dictionary<string, string>(c.Environment) { ["FAKE_HOOK"] = "from-hook" } };
}));
await Discover(new McpCatalogCache(_time), FakeMcp.Server("env,count-starts", LogPath), policy);
Assert.NotNull(seen);
Assert.Equal(ProcessPurpose.McpServer, seen!.Purpose);
Assert.Equal("fake", seen.Origin);
Assert.Equal("from-hook", Assert.Single(FakeMcp.Starts(LogPath)).GetProperty("env").GetProperty("FAKE_HOOK").GetString());
}
[Fact]
public async Task Policy_failure_fails_discovery_with_its_message()
{
var policy = McpTestEnvironment.Policy(_env, o => o.Hooks.Add(_ => throw new InvalidOperationException("boom")));
var ex = await Assert.ThrowsAsync<McpServerStartException>(() => Discover(new McpCatalogCache(_time), FakeMcp.Server(), policy));
Assert.Contains("process environment hook failed:", ex.Message, StringComparison.Ordinal);
Assert.Contains("boom", ex.Message, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,80 @@
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpEndToEndTests
{
private static McpOptions Fake(McpServerConfig config) => new() { Servers = { ["fake"] = config } };
[Fact]
public async Task Image_tool_result_reaches_the_model_as_image_content()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server()));
host.Faux.Enqueue(FauxTurn.Tool("image", new { }), FauxTurn.Say("I see a pixel"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("show me");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.False(result.IsError);
Assert.Equal(new ImageContent(FakeServerProgram.Png1x1, "image/png"), Assert.Single(result.Content));
Assert.Equal("fake", result.Details!.Value.GetProperty("server").GetString());
var sent = host.Faux.Requests[1].Messages.OfType<ToolResultMessage>().Single();
Assert.Contains(sent.Content, c => c is ImageContent);
}
[Fact]
public async Task Fail_tool_becomes_an_error_result_with_the_server_content()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server()));
host.Faux.Enqueue(FauxTurn.Tool("fail", new { }), FauxTurn.Say("it failed"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("try");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.True(result.IsError);
Assert.Contains("fake failure: nothing works", result.Text, StringComparison.Ordinal);
Assert.Contains(result.Content, c => c is ImageContent);
}
[Fact]
public async Task Preset_runs_against_a_fake_playwright_with_workspace_paths()
{
var log = Path.Combine(Path.GetTempPath(), $"volcanic-mcp-preset-{Guid.NewGuid():N}.log");
try
{
await using var host = new McpTestHost(
new McpOptions { Playwright = new PlaywrightOptions { Enabled = true, Command = FakeMcp.AppHostPath } },
env => { env["FAKE_MCP_MODE"] = "count-starts"; env["FAKE_MCP_LOG"] = log; });
host.Faux.Enqueue(
FauxTurn.Tool("browser_navigate", new { url = "https://example.com" }),
FauxTurn.Tool("browser_take_screenshot", new { filename = "browser/shot.png" }),
FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("screenshot example.com");
var texts = session.Messages.OfType<ToolResultMessage>().Select(m => m.Text).ToList();
Assert.Equal(["navigated to https://example.com", "saved browser/shot.png"], texts);
Assert.True(File.Exists(Path.Combine(host.Workspace, "browser", "shot.png")));
var start = FakeMcp.Starts(log)[1];
Assert.Equal(Path.GetFullPath(host.Workspace), Path.GetFullPath(start.GetProperty("cwd").GetString()!));
var args = start.GetProperty("args").EnumerateArray().Select(a => a.GetString()).ToList();
Assert.DoesNotContain("-y", args);
Assert.Equal("chromium", args[args.IndexOf("--browser") + 1]);
Assert.Equal($"{host.Workspace}/browser", args[args.IndexOf("--output-dir") + 1]);
Assert.Equal($"{host.Workspace}/.volcanic/browser-profile", args[args.IndexOf("--user-data-dir") + 1]);
Assert.Contains("--headless", args);
}
finally
{
File.Delete(log);
}
}
}

View File

@@ -0,0 +1,114 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpEnvironmentTests
{
private const string OpenAiKey = "sk-openai-test-value-123";
private const string GithubToken = "ghp_test_token_value_456";
private static McpOptions Fake(McpServerConfig config) => new() { Servers = { ["fake"] = config } };
private static void Secrets(Dictionary<string, string?> env)
{
env["OPENAI_API_KEY"] = OpenAiKey;
env["GITHUB_TOKEN"] = GithubToken;
}
private static Dictionary<string, string> Parse(string text) =>
text.Split('\n').Select(l => l.Split('=', 2)).Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1], OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
private static async Task<Dictionary<string, string>> ServerEnvironmentAsync(McpTestHost host, TimeZoneInfo? zone = null)
{
host.Faux.Enqueue(FauxTurn.Tool("env", new { }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync(zone);
await session.PromptAsync("go");
return Parse(session.Messages.OfType<ToolResultMessage>().Last().Text);
}
[Fact]
public async Task Host_secrets_do_not_reach_the_server()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env")), Secrets);
var env = await ServerEnvironmentAsync(host);
Assert.False(env.ContainsKey("OPENAI_API_KEY"));
Assert.False(env.ContainsKey("GITHUB_TOKEN"));
Assert.True(env.ContainsKey("PATH"));
Assert.Equal("env", env["FAKE_MCP_MODE"]);
}
[Fact]
public async Task Explicit_env_entry_delivers_a_denied_variable()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env", configure: c => c.Env["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}")), Secrets);
var env = await ServerEnvironmentAsync(host);
Assert.Equal(GithubToken, env["GITHUB_TOKEN"]);
Assert.False(env.ContainsKey("OPENAI_API_KEY"));
}
[Fact]
public async Task Discovery_and_session_process_get_the_same_environment()
{
await using var host = new McpTestHost(h => Fake(FakeMcp.Server("env,count-starts", h.LogPath, c => c.Env["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}")), Secrets);
await ServerEnvironmentAsync(host);
var starts = FakeMcp.Starts(host.LogPath);
Assert.Equal(2, starts.Count);
Dictionary<string, string?> Env(int i) => starts[i].GetProperty("env").EnumerateObject()
.Where(p => p.Name != "TZ").ToDictionary(p => p.Name, p => p.Value.GetString());
var discovery = Env(0);
var session = Env(1);
Assert.Equal(discovery.OrderBy(kv => kv.Key, StringComparer.Ordinal), session.OrderBy(kv => kv.Key, StringComparer.Ordinal));
Assert.Equal(GithubToken, session["GITHUB_TOKEN"]);
Assert.False(session.ContainsKey("OPENAI_API_KEY"));
}
[Fact]
public async Task Session_process_gets_the_session_time_zone()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env")));
var env = await ServerEnvironmentAsync(host, TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo"));
// The policy skips TZ on Windows, where the OS doesn't use it (process-environment-hook spec §3).
if (OperatingSystem.IsWindows()) Assert.False(env.ContainsKey("TZ"));
else Assert.Equal("Asia/Tokyo", env["TZ"]);
}
private sealed class SpawnWatcher : IExtension
{
public ProcessSpawnContext? Seen { get; private set; }
public string Name => "spawn-watcher";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
api.On<ProcessSpawnEvent>((e, _, _) =>
{
if (e.Context.Purpose != ProcessPurpose.McpServer) return ValueTask.CompletedTask;
Seen = e.Context;
e.Context = e.Context with { Environment = new Dictionary<string, string>(e.Context.Environment) { ["FROM_HANDLER"] = "yes" } };
return ValueTask.CompletedTask;
});
return ValueTask.CompletedTask;
}
}
[Fact]
public async Task Process_spawn_event_handlers_see_session_process_spawns()
{
var watcher = new SpawnWatcher();
await using var host = new McpTestHost(Fake(FakeMcp.Server("env")), extra: [watcher]);
var env = await ServerEnvironmentAsync(host);
Assert.Equal("yes", env["FROM_HANDLER"]);
Assert.Equal("fake", watcher.Seen!.Origin);
Assert.Equal(host.Workspace, watcher.Seen.Cwd);
}
}

View File

@@ -0,0 +1,305 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpExtensionTests
{
private sealed class ToolExtension(ITool tool) : IExtension
{
public string Name => "extra-tools";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) { api.RegisterTool(tool); return ValueTask.CompletedTask; }
}
public sealed record SendArgs(string Path);
private static McpOptions With(params (string Name, McpServerConfig Config)[] servers)
{
var options = new McpOptions();
foreach (var (name, config) in servers) options.Servers[name] = config;
return options;
}
private static McpOptions PresetOnFake() => new() { Playwright = new PlaywrightOptions { Enabled = true, Command = FakeMcp.AppHostPath } };
private static string LastToolText(AgentSession session) => session.Messages.OfType<ToolResultMessage>().Last().Text;
private static string LastCustomText(AgentSession session) => session.Messages.OfType<CustomMessage>().Last().Text;
private static int Pid(string log, int index) => FakeMcp.Starts(log)[index].GetProperty("pid").GetInt32();
[Fact]
public async Task Activation_registers_one_tool_per_mcp_tool()
{
await using var host = new McpTestHost(With(("fake", FakeMcp.Server())));
var session = await host.CreateSessionAsync();
foreach (var name in new[] { "echo", "image", "fail", "slow", "weird_name_x", "weird_name_x_2", "noschema", FakeServerProgram.LongToolName[..48] })
Assert.Contains(name, session.ActiveTools);
Assert.Empty(session.ExtensionErrors);
}
[Fact]
public async Task Activation_starts_no_session_process()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
await host.CreateSessionAsync();
var start = Assert.Single(FakeMcp.Starts(host.LogPath));
Assert.Contains("volcanic-mcp-discovery", start.GetProperty("cwd").GetString()!, StringComparison.Ordinal);
}
[Fact]
public async Task First_call_starts_the_process_in_the_workspace_with_expanded_args()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath, c => c.Args.AddRange(["--marker", "${workspace}/m"])))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "hi" }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Equal("echo: hi", LastToolText(session));
var starts = FakeMcp.Starts(host.LogPath);
Assert.Equal(2, starts.Count);
Assert.Equal(Path.GetFullPath(host.Workspace), Path.GetFullPath(starts[1].GetProperty("cwd").GetString()!));
Assert.Contains($"{host.Workspace}/m", starts[1].GetProperty("args").EnumerateArray().Select(a => a.GetString()));
}
[Fact]
public async Task User_settings_servers_are_merged_after_the_host_servers()
{
await using var host = new McpTestHost(With(("fake", FakeMcp.Server())));
var user = new Dictionary<string, McpServerConfig>
{
["fake"] = new() { Command = "user-command-not-used" },
["extra"] = FakeMcp.Server(configure: c => c.ToolPrefix = "x_"),
};
File.WriteAllText(Path.Combine(host.AgentDir, "settings.json"), System.Text.Json.JsonSerializer.Serialize(new { mcpServers = user }));
var session = await host.CreateSessionAsync();
Assert.Contains("echo", session.ActiveTools);
Assert.Contains("x_echo", session.ActiveTools);
await session.PromptAsync("/mcp fake");
var details = LastCustomText(session);
Assert.Contains("Command: dotnet ", details, StringComparison.Ordinal);
Assert.DoesNotContain("user-command-not-used", details, StringComparison.Ordinal);
Assert.True(host.Logs.Logger.Has(LogLevel.Information, "MCP server 'fake' in the user settings is ignored"));
}
[Fact]
public async Task Concurrent_sessions_share_one_discovery()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
await Task.WhenAll(host.CreateSessionAsync(), host.CreateSessionAsync(), host.CreateSessionAsync());
Assert.Single(FakeMcp.Starts(host.LogPath));
}
[Fact]
public async Task Session_activates_with_the_other_servers_when_one_hangs()
{
await using var host = new McpTestHost(With(
("hang", FakeMcp.Server("hang-on-start", configure: c => c.StartupTimeoutSeconds = 1)),
("ok", FakeMcp.Server())));
var session = await host.CreateSessionAsync();
Assert.Contains("echo", session.ActiveTools);
Assert.Empty(session.ExtensionErrors);
await session.PromptAsync("/mcp");
var report = LastCustomText(session);
Assert.Contains("- hang: failed: hang MCP server did not start within 1s", report, StringComparison.Ordinal);
Assert.Contains("- ok: not started · 8 tools", report, StringComparison.Ordinal);
}
[Fact]
public async Task Tool_named_like_a_builtin_is_refused_and_the_builtin_stays()
{
await using var host = new McpTestHost(With(("clash", FakeMcp.Server("builtin-clash"))));
File.WriteAllText(Path.Combine(host.Workspace, "x.txt"), "hello from the built-in read");
host.Faux.Enqueue(FauxTurn.Tool("read", new { path = "x.txt" }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var text = LastToolText(session);
Assert.Contains("hello from the built-in read", text, StringComparison.Ordinal);
Assert.DoesNotContain("called read", text, StringComparison.Ordinal);
Assert.True(host.Logs.Logger.Has(LogLevel.Warning, "toolPrefix"));
}
[Fact]
public async Task Same_tool_on_two_servers_the_first_by_name_wins()
{
var logA = Path.Combine(Path.GetTempPath(), $"volcanic-mcp-a-{Guid.NewGuid():N}.log");
var logB = Path.Combine(Path.GetTempPath(), $"volcanic-mcp-b-{Guid.NewGuid():N}.log");
try
{
await using var host = new McpTestHost(With(("b", FakeMcp.Server("count-starts", logB)), ("a", FakeMcp.Server("count-starts", logA))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "who" }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Equal(2, FakeMcp.Starts(logA).Count); // discovery + the session process that served echo
Assert.Single(FakeMcp.Starts(logB)); // discovery only
Assert.Contains(host.Logs.Logger.Messages(LogLevel.Warning), m => m.Contains("echo", StringComparison.Ordinal) && m.Contains("a and b", StringComparison.Ordinal));
}
finally
{
File.Delete(logA);
File.Delete(logB);
}
}
[Fact]
public async Task Browser_section_appears_only_while_the_preset_tools_are_active()
{
await using var host = new McpTestHost(PresetOnFake());
host.Faux.Enqueue(FauxTurn.Say("one"), FauxTurn.Say("two"));
var session = await host.CreateSessionAsync();
Assert.Contains("browser_navigate", session.ActiveTools);
await session.PromptAsync("first");
Assert.Contains("## Browser", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
session.SetActiveTools(["read", "bash", "edit", "write"]);
await session.PromptAsync("second");
Assert.DoesNotContain("## Browser", host.Faux.Requests[1].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Browser_section_mentions_send_only_when_a_send_tool_exists()
{
var send = new DelegateTool<SendArgs>("send", "Send a workspace file", (_, _, _) => Task.FromResult(ToolResult.Text("sent")));
await using (var withSend = new McpTestHost(PresetOnFake(), extra: [new ToolExtension(send)]))
{
withSend.Faux.Enqueue(FauxTurn.Say("ok"));
await (await withSend.CreateSessionAsync()).PromptAsync("hi");
Assert.Contains("call `send` with that", withSend.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
await using var without = new McpTestHost(PresetOnFake());
without.Faux.Enqueue(FauxTurn.Say("ok"));
await (await without.CreateSessionAsync()).PromptAsync("hi");
Assert.Contains("## Browser", without.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
Assert.DoesNotContain("`send`", without.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Generic_prompt_section_is_added_while_its_tools_are_active()
{
await using var host = new McpTestHost(With(("fake", FakeMcp.Server(configure: c => c.PromptSection = "Use the fake tools carefully."))));
host.Faux.Enqueue(FauxTurn.Say("ok"));
await (await host.CreateSessionAsync()).PromptAsync("hi");
Assert.Contains("Use the fake tools carefully.", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task New_session_stops_the_process_and_the_next_call_starts_a_fresh_one()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"), FauxTurn.Tool("echo", new { text = "2" }), FauxTurn.Say("b"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("first");
var first = Pid(host.LogPath, 1);
await session.NewSessionAsync();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(first), "the old session's process to stop");
await session.PromptAsync("second");
Assert.Equal("echo: 2", LastToolText(session));
Assert.Equal(3, FakeMcp.Starts(host.LogPath).Count);
Assert.NotEqual(first, Pid(host.LogPath, 2));
}
[Fact]
public async Task Disposing_the_session_kills_the_process()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var pid = Pid(host.LogPath, 1);
await session.DisposeAsync();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to be killed");
}
[Fact]
public async Task Mcp_command_lists_servers_without_a_model_call()
{
await using var host = new McpTestHost(With(("fake", FakeMcp.Server())));
var session = await host.CreateSessionAsync();
await session.PromptAsync("/mcp");
Assert.Empty(host.Faux.Requests);
var report = LastCustomText(session);
Assert.Contains("MCP servers:", report, StringComparison.Ordinal);
Assert.Contains("- fake: not started · 8 tools", report, StringComparison.Ordinal);
Assert.Contains("- playwright: disabled (set PLAYWRIGHT_ENABLED=true)", report, StringComparison.Ordinal);
}
[Fact]
public async Task Mcp_command_shows_details_and_the_stderr_tail_for_one_server()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("die-after-first-call,count-starts", h.LogPath, c =>
{
c.Env["FAKE_MCP_DIE_MESSAGE"] = "details please";
c.Env["API_THING"] = "value-not-shown";
}))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var pid = Pid(host.LogPath, 1);
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the fake to die");
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "2" }), FauxTurn.Say("b"));
await session.PromptAsync("again");
await session.PromptAsync("/mcp fake");
var details = LastCustomText(session);
Assert.Contains("fake", details, StringComparison.Ordinal);
Assert.Contains("failed:", details, StringComparison.Ordinal);
Assert.Contains("dying: details please", details, StringComparison.Ordinal);
Assert.Contains("Tools: echo", details, StringComparison.Ordinal);
Assert.Contains("API_THING", details, StringComparison.Ordinal);
Assert.DoesNotContain("value-not-shown", details, StringComparison.Ordinal);
}
[Fact]
public async Task Mcp_restart_stops_the_process()
{
await using var host = new McpTestHost(h => With(("fake", FakeMcp.Server("count-starts", h.LogPath))));
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var pid = Pid(host.LogPath, 1);
await session.PromptAsync("/mcp restart fake");
Assert.Equal("Stopped fake; the next call starts a fresh process.", LastCustomText(session));
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to stop");
}
[Fact]
public void ActivatorUtilities_uses_the_options_constructor_when_options_are_registered()
{
var options = new McpOptions { Servers = { ["x"] = new McpServerConfig { Command = "x" } } };
using var provider = new ServiceCollection().AddSingleton(options).BuildServiceProvider();
var extension = (McpExtension)ActivatorUtilities.CreateInstance(provider, typeof(McpExtension));
Assert.Same(options, extension.Options);
}
[Fact]
public void ActivatorUtilities_falls_back_to_the_parameterless_constructor()
{
using var provider = new ServiceCollection().BuildServiceProvider();
var extension = (McpExtension)ActivatorUtilities.CreateInstance(provider, typeof(McpExtension));
Assert.Empty(extension.Options.Servers);
}
}

View File

@@ -0,0 +1,134 @@
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Mcp;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpLaunchTests
{
private const string Ws = "/work/chat-1";
private static Func<string, string?> Env(params (string Name, string Value)[] vars)
{
var map = vars.ToDictionary(v => v.Name, v => v.Value);
return name => map.TryGetValue(name, out var value) ? value : null;
}
[Fact]
public void BuildRequest_expands_the_workspace_in_command_args_cwd_and_env()
{
var config = new McpServerConfig
{
Command = "${workspace}/bin/server",
Args = ["--out", "${workspace}/out", "--literal", "${env:HOME}"],
Cwd = "${workspace}/sub",
Env = { ["DATA"] = "${workspace}/data" },
};
var request = McpLaunch.BuildRequest("srv", config, Ws, Env(("HOME", "/home/me")));
Assert.Equal(ProcessPurpose.McpServer, request.Purpose);
Assert.Equal("srv", request.Origin);
Assert.Equal("/work/chat-1/bin/server", request.Command);
Assert.Equal(["--out", "/work/chat-1/out", "--literal", "${env:HOME}"], request.Arguments);
Assert.Equal("/work/chat-1/sub", request.Cwd);
Assert.Equal("/work/chat-1/data", request.Environment!["DATA"]);
Assert.Null(request.TimeZoneId);
Assert.Null(request.Scope);
}
[Fact]
public void BuildRequest_defaults_the_cwd_to_the_workspace_and_sends_no_environment_without_env_entries()
{
var request = McpLaunch.BuildRequest("srv", new McpServerConfig { Command = "npx" }, Ws, Env());
Assert.Equal(Ws, request.Cwd);
Assert.Null(request.Environment);
Assert.Empty(request.Arguments);
}
[Fact]
public void Env_references_expand_from_the_host_and_unset_ones_become_empty()
{
var config = new McpServerConfig
{
Command = "x",
Env = { ["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}", ["MISSING"] = "a${env:NOPE}b", ["MIXED"] = "${workspace}:${env:A}" },
};
var env = McpLaunch.BuildRequest("srv", config, Ws, Env(("GITHUB_TOKEN", "ghp_secret_value_1"), ("A", "1"))).Environment!;
Assert.Equal("ghp_secret_value_1", env["GITHUB_TOKEN"]);
Assert.Equal("ab", env["MISSING"]);
Assert.Equal("/work/chat-1:1", env["MIXED"]);
}
[Fact]
public void Null_env_values_are_kept_to_remove_variables()
{
var config = new McpServerConfig { Command = "x", Env = { ["DROP_ME"] = null } };
var env = McpLaunch.BuildRequest("srv", config, Ws, Env()).Environment!;
Assert.True(env.ContainsKey("DROP_ME"));
Assert.Null(env["DROP_ME"]);
}
[Fact]
public void Transport_options_replace_the_environment_instead_of_merging()
{
var spawn = new ProcessSpawnContext(ProcessPurpose.McpServer, "cmd", ["a", "b"], "/cwd",
new Dictionary<string, string> { ["PATH"] = "/bin" }, "scope", "sid", "srv");
var lines = new List<string>();
var options = McpLaunch.TransportOptions("srv", spawn, lines.Add);
Assert.False(options.InheritEnvironmentVariables);
Assert.Equal("srv", options.Name);
Assert.Equal("cmd", options.Command);
Assert.Equal(["a", "b"], options.Arguments!);
Assert.Equal("/cwd", options.WorkingDirectory);
var only = Assert.Single(options.EnvironmentVariables!);
Assert.Equal(("PATH", "/bin"), (only.Key, only.Value));
Assert.Equal(McpLaunch.ShutdownTimeout, options.ShutdownTimeout);
Assert.NotNull(options.StandardErrorLines);
options.StandardErrorLines("oops");
Assert.Equal(["oops"], lines);
}
[Fact]
public void Cache_key_uses_the_unexpanded_configuration_only()
{
McpServerConfig Make() => new() { Command = "npx", Args = ["${workspace}/x"], Env = { ["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}" } };
var key = McpLaunch.CacheKey("srv", Make());
Assert.StartsWith("srv:", key, StringComparison.Ordinal);
Assert.Equal(key, McpLaunch.CacheKey("srv", Make()));
Assert.DoesNotContain("GITHUB", key, StringComparison.Ordinal);
Assert.NotEqual(key, McpLaunch.CacheKey("other", Make()));
}
public static TheoryData<string> Mutations => new() { "command", "args", "env", "cwd", "prefix" };
[Theory]
[MemberData(nameof(Mutations))]
public void Cache_key_changes_with_command_args_env_cwd_or_prefix(string what)
{
McpServerConfig Make() => new() { Command = "npx", Args = ["a"], Env = { ["K"] = "v" } };
var changed = Make();
switch (what)
{
case "command": changed.Command = "node"; break;
case "args": changed.Args.Add("b"); break;
case "env": changed.Env["K"] = "w"; break;
case "cwd": changed.Cwd = "/elsewhere"; break;
case "prefix": changed.ToolPrefix = "p_"; break;
}
Assert.NotEqual(McpLaunch.CacheKey("s", Make()), McpLaunch.CacheKey("s", changed));
}
[Fact]
public void Stderr_tail_keeps_the_last_20_lines()
{
var tail = new StderrTail();
for (var i = 1; i <= 25; i++) tail.Add($"line {i}");
Assert.Equal(20, tail.Lines.Count);
Assert.Equal("line 6", tail.Lines[0]);
Assert.Equal("line 25", tail.Lines[^1]);
tail.Clear();
Assert.Empty(tail.Lines);
}
}

View File

@@ -0,0 +1,182 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpOptionsTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-options", Guid.NewGuid().ToString("N"));
public McpOptionsTests() => Directory.CreateDirectory(_dir);
public void Dispose() { try { Directory.Delete(_dir, true); } catch (IOException) { } }
private static Func<string, string?> Env(params (string Name, string Value)[] vars)
{
var map = vars.ToDictionary(v => v.Name, v => v.Value);
return name => map.TryGetValue(name, out var value) ? value : null;
}
private static McpServerConfig Valid() => new() { Command = "npx", Args = ["-y", "server"] };
[Fact]
public void ApplyEnvironment_reads_the_playwright_variables()
{
var options = new McpOptions();
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", "true"), ("PLAYWRIGHT_HEADLESS", "false"),
("PLAYWRIGHT_BROWSER", "firefox"), ("PLAYWRIGHT_COMMAND", "playwright-mcp"), ("PLAYWRIGHT_EXTRA_ARGS", "--no-sandbox")));
Assert.True(options.Playwright.IsEnabled);
Assert.False(options.Playwright.IsHeadless);
Assert.Equal("firefox", options.Playwright.Browser);
Assert.Equal("playwright-mcp", options.Playwright.Command);
Assert.Equal(["--no-sandbox"], options.Playwright.ExtraArgs!);
Assert.Empty(options.Validate());
}
[Fact]
public void ApplyEnvironment_splits_extra_args_honouring_quotes()
{
var options = new McpOptions();
options.ApplyEnvironment(Env(("PLAYWRIGHT_EXTRA_ARGS", "--device \"iPhone 15\" --viewport-size '1280, 720' --isolated")));
Assert.Equal(["--device", "iPhone 15", "--viewport-size", "1280, 720", "--isolated"], options.Playwright.ExtraArgs!);
}
[Fact]
public void ApplyEnvironment_config_values_win_over_the_environment()
{
var options = new McpOptions { Playwright = new PlaywrightOptions { Enabled = false, Headless = true, Browser = "webkit", Command = "custom", ExtraArgs = ["--a"] } };
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", "true"), ("PLAYWRIGHT_HEADLESS", "false"), ("PLAYWRIGHT_BROWSER", "firefox"),
("PLAYWRIGHT_COMMAND", "playwright-mcp"), ("PLAYWRIGHT_EXTRA_ARGS", "--b")));
Assert.False(options.Playwright.IsEnabled);
Assert.True(options.Playwright.IsHeadless);
Assert.Equal("webkit", options.Playwright.Browser);
Assert.Equal("custom", options.Playwright.Command);
Assert.Equal(["--a"], options.Playwright.ExtraArgs!);
}
[Fact]
public void ApplyEnvironment_treats_empty_variables_as_unset()
{
var options = new McpOptions();
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", ""), ("PLAYWRIGHT_HEADLESS", ""), ("PLAYWRIGHT_BROWSER", ""),
("PLAYWRIGHT_COMMAND", ""), ("PLAYWRIGHT_EXTRA_ARGS", "")));
Assert.False(options.Playwright.IsEnabled);
Assert.True(options.Playwright.IsHeadless);
Assert.Null(options.Playwright.Browser);
Assert.Null(options.Playwright.Command);
Assert.Null(options.Playwright.ExtraArgs);
Assert.Empty(options.Validate());
}
[Fact]
public void ApplyEnvironment_reports_an_invalid_boolean_and_an_unterminated_quote()
{
var options = new McpOptions();
options.ApplyEnvironment(Env(("PLAYWRIGHT_ENABLED", "maybe"), ("PLAYWRIGHT_EXTRA_ARGS", "--device \"iPhone")));
var errors = options.Validate();
Assert.Contains(errors, e => e.Contains("PLAYWRIGHT_ENABLED", StringComparison.Ordinal));
Assert.Contains(errors, e => e.Contains("PLAYWRIGHT_EXTRA_ARGS", StringComparison.Ordinal));
}
[Fact]
public void Validate_rejects_an_empty_command()
{
var options = new McpOptions { Servers = { ["github"] = new McpServerConfig { Command = " " } } };
var error = Assert.Single(options.Validate());
Assert.Contains("github", error, StringComparison.Ordinal);
Assert.Contains("command", error, StringComparison.Ordinal);
}
[Theory]
[InlineData(0, 120, 1800)]
[InlineData(-1, 120, 1800)]
[InlineData(60, 0, 1800)]
[InlineData(60, 120, -1)]
public void Validate_rejects_non_positive_timeouts(int startup, int call, int idle)
{
var config = Valid();
config.StartupTimeoutSeconds = startup;
config.CallTimeoutSeconds = call;
config.IdleTimeoutSeconds = idle;
Assert.Single(config.Validate("s"));
}
[Fact]
public void Validate_accepts_zero_idle_timeout_and_an_ordinary_prefix()
{
var config = Valid();
config.IdleTimeoutSeconds = 0;
config.ToolPrefix = "gh_";
Assert.Empty(config.Validate("s"));
}
[Theory]
[InlineData("gh.")]
[InlineData("a b")]
[InlineData("mcp__x_")]
[InlineData("MCP__x_")]
public void Validate_rejects_bad_prefixes(string prefix)
{
var config = Valid();
config.ToolPrefix = prefix;
Assert.Contains(config.Validate("s"), e => e.Contains("toolPrefix", StringComparison.Ordinal));
}
[Fact]
public void UserSettings_load_mcpServers_in_document_order()
{
File.WriteAllText(Path.Combine(_dir, "settings.json"), """
{
// comments are allowed
"extensions": [],
"mcpServers": {
"zeta": { "command": "z", "args": ["1"], "env": { "A": "x", "REMOVED": null }, "stderrLevel": "Information" },
"alpha": { "command": "a", "toolPrefix": "al_", "parallel": true, "idleTimeoutSeconds": 0 },
}
}
""");
var servers = McpUserSettings.Load(_dir, new RecordingLogger());
Assert.Equal(["zeta", "alpha"], servers.Select(s => s.Key));
var zeta = servers[0].Value;
Assert.Equal("z", zeta.Command);
Assert.Equal(["1"], zeta.Args);
Assert.Equal("x", zeta.Env["A"]);
Assert.True(zeta.Env.ContainsKey("REMOVED"));
Assert.Null(zeta.Env["REMOVED"]);
Assert.Equal(LogLevel.Information, zeta.StderrLevel);
var alpha = servers[1].Value;
Assert.Equal("al_", alpha.ToolPrefix);
Assert.True(alpha.Parallel);
Assert.Equal(0, alpha.IdleTimeoutSeconds);
Assert.Equal(60, alpha.StartupTimeoutSeconds);
}
[Fact]
public void UserSettings_missing_file_or_section_is_empty()
{
Assert.Empty(McpUserSettings.Load(_dir, new RecordingLogger()));
File.WriteAllText(Path.Combine(_dir, "settings.json"), """{ "extensions": [] }""");
Assert.Empty(McpUserSettings.Load(_dir, new RecordingLogger()));
}
[Fact]
public void UserSettings_malformed_file_is_ignored_with_a_warning()
{
File.WriteAllText(Path.Combine(_dir, "settings.json"), "{ \"mcpServers\": { ");
var logger = new RecordingLogger();
Assert.Empty(McpUserSettings.Load(_dir, logger));
Assert.True(logger.Has(LogLevel.Warning, "settings.json"));
}
[Fact]
public void AgentDir_prefers_VOLCANIC_AGENT_DIR()
{
Assert.Equal(_dir, McpUserSettings.AgentDir(Env(("VOLCANIC_AGENT_DIR", _dir))));
var fallback = McpUserSettings.AgentDir(Env());
Assert.EndsWith(Path.Combine(".volcanic", "agent"), fallback, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,77 @@
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpRedactionTests
{
private const string GithubToken = "ghp_test_token_value_456";
private static McpOptions Fake(McpServerConfig config) => new() { Servers = { ["fake"] = config } };
private static void Token(Dictionary<string, string?> env) => env["GITHUB_TOKEN"] = GithubToken;
[Fact]
public async Task Env_tool_output_is_redacted_before_the_next_request()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env", configure: c => c.Env["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}")), Token, [GithubToken]);
host.Faux.Enqueue(FauxTurn.Tool("env", new { }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var sent = host.Faux.Requests[1].Messages.OfType<ToolResultMessage>().Last().Text;
Assert.Contains("GITHUB_TOKEN=[REDACTED]", sent, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, sent, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, session.Messages.OfType<ToolResultMessage>().Last().Text, StringComparison.Ordinal);
}
[Fact]
public async Task Exit_error_stderr_tail_is_redacted()
{
await using var host = new McpTestHost(
h => Fake(FakeMcp.Server("die-after-first-call,count-starts", h.LogPath, c => c.Env["FAKE_MCP_DIE_MESSAGE"] = "${env:GITHUB_TOKEN}")),
Token, [GithubToken]);
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("first");
var pid = FakeMcp.Starts(host.LogPath)[1].GetProperty("pid").GetInt32();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the fake to die");
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "2" }), FauxTurn.Say("b"));
await session.PromptAsync("second");
var error = session.Messages.OfType<ToolResultMessage>().Last();
Assert.True(error.IsError);
Assert.Contains("fake MCP server exited (3)", error.Text, StringComparison.Ordinal);
Assert.Contains("dying: [REDACTED]", error.Text, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, error.Text, StringComparison.Ordinal);
var sent = host.Faux.Requests[^1].Messages.OfType<ToolResultMessage>().Last().Text;
Assert.Contains("dying: [REDACTED]", sent, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, sent, StringComparison.Ordinal);
}
[Fact]
public async Task Mcp_command_and_the_log_redact_the_stderr_tail_and_last_error()
{
await using var host = new McpTestHost(
h => Fake(FakeMcp.Server("die-after-first-call,count-starts", h.LogPath, c => c.Env["FAKE_MCP_DIE_MESSAGE"] = "${env:GITHUB_TOKEN}")),
Token, [GithubToken]);
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("first");
var pid = FakeMcp.Starts(host.LogPath)[1].GetProperty("pid").GetInt32();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the fake to die");
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "2" }), FauxTurn.Say("b"));
await session.PromptAsync("second");
await session.PromptAsync("/mcp fake");
var details = session.Messages.OfType<CustomMessage>().Last().Text;
Assert.Contains("dying: [REDACTED]", details, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, details, StringComparison.Ordinal);
lock (host.Logs.Logger.Entries)
Assert.DoesNotContain(host.Logs.Logger.Entries, e => e.Message.Contains(GithubToken, StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,124 @@
using System.Text;
using System.Text.Json;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.Mcp;
using ContentBlock = ModelContextProtocol.Protocol.ContentBlock;
using TextContent = Volcanic.AgenticCore.Abstractions.Messages.TextContent;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpResultMapperTests
{
private const string Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
private static TextContentBlock Text(string text) => new() { Text = text };
private static ImageContentBlock Image(string base64, string mime = "image/png") => new() { Data = Encoding.UTF8.GetBytes(base64), MimeType = mime };
private static ToolResult Map(params ContentBlock[] blocks) => McpResultMapper.Map("srv", "tool", new CallToolResult { Content = blocks });
private static string OnlyText(ToolResult r) => Assert.IsType<TextContent>(Assert.Single(r.Content)).Text;
[Fact]
public void Text_blocks_are_joined_into_one_text_content() => Assert.Equal("a\nb", OnlyText(Map(Text("a"), Text("b"))));
[Fact]
public void Image_becomes_image_content()
{
var result = Map(Text("shot:"), Image(Png));
Assert.Equal(2, result.Content.Count);
Assert.Equal("shot:", Assert.IsType<TextContent>(result.Content[0]).Text);
Assert.Equal(new ImageContent(Png, "image/png"), result.Content[1]);
Assert.Equal(new ImageContent(Png, "image/png"), Assert.Single(Map(Image(Png)).Content));
}
[Fact]
public void Oversized_image_is_replaced_by_a_note()
{
var big = Convert.ToBase64String(new byte[McpResultMapper.MaxImageBytes + 1]);
Assert.Equal($"[image omitted: image/png, {McpResultMapper.MaxImageBytes + 1} bytes]", OnlyText(Map(Image(big))));
}
[Fact]
public void Image_at_the_limit_is_kept()
{
var atLimit = Convert.ToBase64String(new byte[McpResultMapper.MaxImageBytes]);
Assert.IsType<ImageContent>(Assert.Single(Map(Image(atLimit)).Content));
}
[Fact]
public void Audio_is_replaced_by_a_note() =>
Assert.Equal("[audio omitted: audio/wav, 3 bytes]",
OnlyText(Map(new AudioContentBlock { Data = Encoding.UTF8.GetBytes(Convert.ToBase64String([1, 2, 3])), MimeType = "audio/wav" })));
[Fact]
public void Embedded_text_resource_is_headed_with_its_uri() =>
Assert.Equal("Resource file:///notes.txt:\nhello",
OnlyText(Map(new EmbeddedResourceBlock { Resource = new TextResourceContents { Uri = "file:///notes.txt", Text = "hello" } })));
[Fact]
public void Embedded_blob_resource_is_summarised() =>
Assert.Equal("[resource file:///a.bin: application/octet-stream, 4 bytes omitted]",
OnlyText(Map(new EmbeddedResourceBlock
{
Resource = new BlobResourceContents { Uri = "file:///a.bin", MimeType = "application/octet-stream", Blob = Encoding.UTF8.GetBytes(Convert.ToBase64String([1, 2, 3, 4])) },
})));
[Fact]
public void Resource_link_is_described() =>
Assert.Equal("Resource link: file:///report.pdf (report)", OnlyText(Map(new ResourceLinkBlock { Uri = "file:///report.pdf", Name = "report" })));
[Fact]
public void Structured_content_is_used_when_there_is_no_content()
{
var result = McpResultMapper.Map("srv", "tool", new CallToolResult { Content = [], StructuredContent = JsonSerializer.SerializeToElement(new { a = 1 }) });
Assert.Equal("""{"a":1}""", OnlyText(result));
}
[Fact]
public void Empty_result_says_no_output() => Assert.Equal("(no output)", OnlyText(Map()));
[Fact]
public void Long_text_is_truncated_with_the_core_note()
{
var text = string.Join('\n', Enumerable.Range(1, 2500).Select(i => $"line {i}"));
var result = Map(Text(text));
var shown = OnlyText(result);
Assert.StartsWith("line 1\n", shown, StringComparison.Ordinal);
Assert.Contains("line 2000\n", shown, StringComparison.Ordinal);
Assert.DoesNotContain("line 2001", shown, StringComparison.Ordinal);
Assert.Contains("[Output truncated: showing 2000 of 2500 lines", shown, StringComparison.Ordinal);
Assert.True(result.Details!.Value.GetProperty("truncated").GetBoolean());
}
[Fact]
public void Large_text_is_truncated_by_bytes()
{
var text = string.Join('\n', Enumerable.Range(1, 100).Select(_ => new string('x', 1000)));
var shown = OnlyText(Map(Text(text)));
Assert.Contains("[Output truncated: showing 51 of 100 lines", shown, StringComparison.Ordinal);
}
[Fact]
public void Details_record_server_tool_error_truncation_and_images()
{
var details = Map(Text("t"), Image(Png), Image(Png)).Details!.Value;
Assert.Equal("srv", details.GetProperty("server").GetString());
Assert.Equal("tool", details.GetProperty("tool").GetString());
Assert.False(details.GetProperty("isError").GetBoolean());
Assert.False(details.GetProperty("truncated").GetBoolean());
Assert.Equal(2, details.GetProperty("images").GetInt32());
}
[Fact]
public void IsError_throws_ToolExecutionException_with_the_server_content()
{
var ex = Assert.Throws<ToolExecutionException>(() =>
McpResultMapper.Map("srv", "tool", new CallToolResult { IsError = true, Content = [Text("denied"), Image(Png)] }));
Assert.Equal("denied", Assert.IsType<TextContent>(ex.Result.Content[0]).Text);
Assert.Equal(new ImageContent(Png, "image/png"), ex.Result.Content[1]);
Assert.True(ex.Result.Details!.Value.GetProperty("isError").GetBoolean());
Assert.Equal("denied", ex.Message);
}
}

View File

@@ -0,0 +1,232 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpServerConnectionTests : IAsyncLifetime
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-connection", Guid.NewGuid().ToString("N"));
private readonly Dictionary<string, string?> _env = McpTestEnvironment.Snapshot();
private readonly RecordingLogger _logger = new();
private readonly FakeTimeProvider _time = new();
private readonly List<McpServerConnection> _connections = [];
private string Workspace => Path.Combine(_dir, "ws");
private string LogPath => Path.Combine(_dir, "fake.log");
public Task InitializeAsync() { Directory.CreateDirectory(Workspace); return Task.CompletedTask; }
public async Task DisposeAsync()
{
foreach (var connection in _connections) await connection.DisposeAsync();
try { Directory.Delete(_dir, true); } catch (IOException) { }
}
private McpServerConnection Connect(McpServerConfig config, McpCatalog? catalog = null)
{
var connection = new McpServerConnection("fake", config, _logger, McpTestEnvironment.Lookup(_env), catalog, _time);
_connections.Add(connection);
return connection;
}
private McpStubContext Context(Action<ProcessEnvironmentOptions>? configure = null) => new(Workspace, McpTestEnvironment.Policy(_env, configure));
private static JsonElement Args(object value) => JsonSerializer.SerializeToElement(value);
private static string TextOf(CallToolResult result) => Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
private int StartPid(int index) => FakeMcp.Starts(LogPath)[index].GetProperty("pid").GetInt32();
[Fact]
public async Task First_call_starts_the_process_through_the_context()
{
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
var context = Context();
Assert.Equal(McpConnectionStatus.NotStarted, connection.Status);
Assert.Equal("echo: hi", TextOf(await connection.CallAsync("echo", Args(new { text = "hi" }), context, CancellationToken.None)));
var request = Assert.Single(context.Requests);
Assert.Equal(ProcessPurpose.McpServer, request.Purpose);
Assert.Equal("fake", request.Origin);
Assert.Equal(Workspace, request.Cwd);
Assert.Equal(Path.GetFullPath(Workspace), Path.GetFullPath(Assert.Single(FakeMcp.Starts(LogPath)).GetProperty("cwd").GetString()!));
Assert.Equal(McpConnectionStatus.Running, connection.Status);
}
[Fact]
public async Task Second_call_reuses_the_process()
{
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
await connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None);
Assert.Single(FakeMcp.Starts(LogPath));
Assert.Equal(1, connection.StartCount);
}
[Fact]
public async Task Call_uses_the_original_mcp_name_on_the_wire()
{
var connection = Connect(FakeMcp.Server());
Assert.Equal("called weird.name/x", TextOf(await connection.CallAsync("weird.name/x", Args(new { }), Context(), CancellationToken.None)));
}
[Fact]
public async Task Cancelling_a_call_sends_notifications_cancelled()
{
var connection = Connect(FakeMcp.Server("normal", LogPath));
await connection.CallAsync("echo", Args(new { text = "warm" }), Context(), CancellationToken.None);
using var cts = new CancellationTokenSource();
var call = connection.CallAsync("slow", Args(new { }), Context(), cts.Token);
await Task.Delay(300);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => call);
await FakeMcp.WaitForAsync(() => FakeMcp.ReadLog(LogPath).Any(e => e.GetProperty("event").GetString() == "cancelled"), "the server to see notifications/cancelled");
Assert.Equal("echo: after", TextOf(await connection.CallAsync("echo", Args(new { text = "after" }), Context(), CancellationToken.None)));
Assert.Equal(1, connection.StartCount); // the notification reached the same live process, nothing restarted
}
[Fact]
public async Task Call_timeout_names_the_tool_and_the_limit()
{
var connection = Connect(FakeMcp.Server(configure: c => c.CallTimeoutSeconds = 1));
await connection.CallAsync("echo", Args(new { text = "warm" }), Context(), CancellationToken.None);
var ex = await Assert.ThrowsAsync<TimeoutException>(() => connection.CallAsync("slow", Args(new { }), Context(), CancellationToken.None));
Assert.Equal("slow timed out after 1s", ex.Message);
}
[Fact]
public async Task Server_exit_fails_the_next_call_and_the_one_after_restarts()
{
var connection = Connect(FakeMcp.Server("die-after-first-call,count-starts", LogPath, c => c.Env["FAKE_MCP_DIE_MESSAGE"] = "bye from the fake"));
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
var pid = StartPid(0);
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the fake to die");
await FakeMcp.WaitForAsync(() => connection.ProcessExited, "the connection to see the exit");
var ex = await Assert.ThrowsAsync<McpServerExitedException>(() => connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None));
Assert.Contains($"fake MCP server exited ({FakeServerProgram.DieExitCode}):", ex.Message, StringComparison.Ordinal);
Assert.Contains("dying: bye from the fake", ex.Message, StringComparison.Ordinal);
Assert.EndsWith("It restarts on the next call; browser state was lost.", ex.Message, StringComparison.Ordinal);
Assert.Equal(McpConnectionStatus.Failed, connection.Status);
Assert.Equal(ex.Message, connection.LastError);
Assert.Equal("echo: 3", TextOf(await connection.CallAsync("echo", Args(new { text = "3" }), Context(), CancellationToken.None)));
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
Assert.Equal(McpConnectionStatus.Running, connection.Status);
}
[Fact]
public async Task Stop_after_a_failure_resets_the_status_and_keeps_the_last_error()
{
var connection = Connect(FakeMcp.Server("crash-on-start"));
await Assert.ThrowsAsync<McpServerStartException>(() => connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None));
Assert.Equal(McpConnectionStatus.Failed, connection.Status);
await connection.StopAsync();
Assert.Equal(McpConnectionStatus.NotStarted, connection.Status);
Assert.Contains("fake crash on start", connection.LastError, StringComparison.Ordinal);
}
[Fact]
public async Task Idle_timeout_stops_the_process()
{
var connection = Connect(FakeMcp.Server("count-starts", LogPath, c => c.IdleTimeoutSeconds = 60));
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
var pid = StartPid(0);
_time.Advance(TimeSpan.FromSeconds(59));
await Task.Delay(300);
Assert.False(FakeMcp.HasExited(pid));
_time.Advance(TimeSpan.FromSeconds(2));
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the idle process to stop");
await FakeMcp.WaitForAsync(() => connection.Status == McpConnectionStatus.NotStarted, "the status to reset");
await connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None);
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
}
[Fact]
public async Task Zero_idle_timeout_never_stops_the_process()
{
var connection = Connect(FakeMcp.Server("count-starts", LogPath, c => c.IdleTimeoutSeconds = 0));
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
_time.Advance(TimeSpan.FromDays(1));
await Task.Delay(300);
Assert.False(FakeMcp.HasExited(StartPid(0)));
}
[Fact]
public async Task Start_failure_is_reported_and_the_next_call_retries()
{
var connection = Connect(FakeMcp.Server("crash-on-start,count-starts", LogPath));
var ex = await Assert.ThrowsAsync<McpServerStartException>(() => connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None));
Assert.Contains("fake crash on start", ex.Message, StringComparison.Ordinal);
Assert.Equal(McpConnectionStatus.Failed, connection.Status);
await Assert.ThrowsAsync<McpServerStartException>(() => connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None));
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
}
[Fact]
public async Task Spawn_policy_failure_fails_the_call_with_its_message()
{
var connection = Connect(FakeMcp.Server());
var context = Context(o => o.Hooks.Add(_ => throw new InvalidOperationException("nope")));
var ex = await Assert.ThrowsAsync<McpServerStartException>(() => connection.CallAsync("echo", Args(new { text = "1" }), context, CancellationToken.None));
Assert.Contains("process environment hook failed:", ex.Message, StringComparison.Ordinal);
Assert.Contains("nope", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Stop_stops_the_process_and_the_next_call_restarts()
{
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
var pid = StartPid(0);
await connection.StopAsync();
await connection.StopAsync();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to stop");
Assert.Equal(McpConnectionStatus.NotStarted, connection.Status);
await connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None);
Assert.Equal(2, FakeMcp.Starts(LogPath).Count);
}
[Fact]
public async Task Dispose_is_idempotent_and_kills_the_process()
{
var connection = Connect(FakeMcp.Server("count-starts", LogPath));
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
var pid = StartPid(0);
await connection.DisposeAsync();
await connection.DisposeAsync();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the process to be killed");
await Assert.ThrowsAsync<ObjectDisposedException>(() => connection.CallAsync("echo", Args(new { text = "2" }), Context(), CancellationToken.None));
}
[Fact]
public async Task Changed_tool_list_is_logged_as_a_warning()
{
var catalog = new McpCatalog("fake", "fake-mcp", "1.2.3", [new McpCatalogTool("echo", "echo", "Echoes", null, Args(new { type = "object" }))]);
var connection = Connect(FakeMcp.Server(), catalog);
await connection.CallAsync("echo", Args(new { text = "1" }), Context(), CancellationToken.None);
await FakeMcp.WaitForAsync(() => _logger.Has(LogLevel.Warning, "changed since discovery"), "the catalogue check");
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpServerSetTests
{
private static McpServerConfig Server(string command) => new() { Command = command };
[Fact]
public void Host_servers_by_name_then_the_preset_then_user_servers_in_file_order()
{
var options = new McpOptions { Servers = { ["b"] = Server("b"), ["a"] = Server("a") }, Playwright = new PlaywrightOptions { Enabled = true } };
var entries = McpServerSet.Resolve(options, [new("zuser", Server("z")), new("user", Server("u"))], new RecordingLogger());
Assert.Equal(["a", "b", "playwright", "zuser", "user"], entries.Select(e => e.Name));
var preset = entries[2];
Assert.Equal("browser", preset.SectionKey);
Assert.Equal("npx", preset.Config.Command);
Assert.False(preset.Disabled);
}
[Fact]
public void The_first_source_wins_on_a_server_name()
{
var options = new McpOptions { Servers = { ["github"] = Server("host") } };
var logger = new RecordingLogger();
var entries = McpServerSet.Resolve(options, [new("github", Server("user")), new("extra", Server("x"))], logger);
Assert.Equal("host", entries.Single(e => e.Name == "github").Config.Command);
Assert.Contains(entries, e => e.Name == "extra");
Assert.True(logger.Has(LogLevel.Information, "github"));
}
[Fact]
public void An_explicit_playwright_server_replaces_the_preset()
{
var options = new McpOptions { Servers = { ["playwright"] = Server("mine") }, Playwright = new PlaywrightOptions { Enabled = true } };
var logger = new RecordingLogger();
var entry = Assert.Single(McpServerSet.Resolve(options, [], logger));
Assert.Equal("mine", entry.Config.Command);
Assert.Equal("playwright", entry.SectionKey);
Assert.True(logger.Has(LogLevel.Warning, "preset"));
}
[Fact]
public void Disabled_preset_is_listed_last_when_its_name_is_free()
{
var entries = McpServerSet.Resolve(new McpOptions(), [new("user", Server("u"))], new RecordingLogger());
Assert.Equal(["user", "playwright"], entries.Select(e => e.Name));
Assert.True(entries[1].Disabled);
Assert.Equal("set PLAYWRIGHT_ENABLED=true", entries[1].DisabledHint);
var taken = McpServerSet.Resolve(new McpOptions(), [new("playwright", Server("u"))], new RecordingLogger());
Assert.False(Assert.Single(taken).Disabled);
}
[Fact]
public void Disabled_and_invalid_servers_are_listed_with_their_state()
{
var disabled = Server("d");
disabled.Enabled = false;
var options = new McpOptions { Servers = { ["off"] = disabled, ["bad"] = Server("") } };
var entries = McpServerSet.Resolve(options, [], new RecordingLogger());
Assert.True(entries.Single(e => e.Name == "off").Disabled);
Assert.Contains("command is required", entries.Single(e => e.Name == "bad").Problem, StringComparison.Ordinal);
}
[Fact]
public void Generic_prompt_section_comes_from_the_config()
{
var config = Server("x");
config.PromptSection = "Use x wisely.";
var entry = McpServerEntry.For("x", config);
Assert.Equal("x", entry.SectionKey);
Assert.Equal("Use x wisely.", entry.PromptSection(true));
Assert.Equal(PlaywrightPreset.PromptSection(false), McpServerEntry.Preset(new PlaywrightOptions { Enabled = true }).PromptSection(false));
}
}

View File

@@ -0,0 +1,95 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpToolTests : IAsyncLifetime
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-tool", Guid.NewGuid().ToString("N"));
private readonly Dictionary<string, string?> _env = McpTestEnvironment.Snapshot();
private McpServerConnection _connection = null!;
public Task InitializeAsync()
{
Directory.CreateDirectory(_dir);
_connection = new McpServerConnection("fake", FakeMcp.Server(), new RecordingLogger(), McpTestEnvironment.Lookup(_env));
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await _connection.DisposeAsync();
try { Directory.Delete(_dir, true); } catch (IOException) { }
}
private static JsonElement Json(string json) => JsonSerializer.Deserialize<JsonElement>(json);
private static McpCatalogTool Entry(string name = "echo", string? description = "Echoes text back", string? title = null, JsonElement schema = default) =>
new(name, "exp_" + name, description, title, schema.ValueKind == JsonValueKind.Undefined ? Json(FakeServerProgram.EchoSchema) : schema);
private Task<ToolResult> Run(McpTool tool, object args) =>
tool.ExecuteAsync(new ToolCallContent("c1", tool.Name, JsonSerializer.SerializeToElement(args)),
new ToolInvocation("c1", new McpStubContext(_dir, McpTestEnvironment.Policy(_env)), null), CancellationToken.None);
[Fact]
public void Exposes_name_description_and_schema_verbatim()
{
var tool = new McpTool(Entry(), _connection, parallel: false);
Assert.Equal("exp_echo", tool.Name);
Assert.Equal("Echoes text back", tool.Description);
Assert.True(JsonElement.DeepEquals(Json(FakeServerProgram.EchoSchema), tool.Parameters));
}
[Fact]
public void Description_falls_back_to_title_then_name()
{
Assert.Equal("Echo tool", new McpTool(Entry(description: null, title: "Echo tool"), _connection, false).Description);
Assert.Equal("echo", new McpTool(Entry(description: " ", title: null), _connection, false).Description);
}
[Fact]
public void Missing_or_non_object_schema_becomes_an_object_schema()
{
var missing = new McpTool(new McpCatalogTool("x", "x", "d", null, default), _connection, false);
var boolean = new McpTool(Entry(schema: Json("true")), _connection, false);
Assert.True(JsonElement.DeepEquals(Json("""{"type":"object"}"""), missing.Parameters));
Assert.True(JsonElement.DeepEquals(Json("""{"type":"object"}"""), boolean.Parameters));
}
[Fact]
public void Top_level_dollar_schema_is_dropped()
{
var tool = new McpTool(Entry(schema: Json("""{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"a":{"type":"string"}}}""")), _connection, false);
Assert.False(tool.Parameters.TryGetProperty("$schema", out _));
Assert.Equal("string", tool.Parameters.GetProperty("properties").GetProperty("a").GetProperty("type").GetString());
}
[Fact]
public void Tools_are_sequential_unless_the_server_is_parallel_and_have_no_prompt_snippet()
{
ITool sequential = new McpTool(Entry(), _connection, parallel: false);
ITool parallel = new McpTool(Entry(), _connection, parallel: true);
Assert.Equal(ToolExecutionMode.Sequential, sequential.ExecutionMode);
Assert.Equal(ToolExecutionMode.Parallel, parallel.ExecutionMode);
Assert.Null(sequential.PromptSnippet);
}
[Fact]
public async Task Image_tool_returns_image_content()
{
var result = await Run(new McpTool(Entry("image"), _connection, false), new { });
var image = Assert.IsType<ImageContent>(Assert.Single(result.Content));
Assert.Equal(new ImageContent(FakeServerProgram.Png1x1, "image/png"), image);
}
[Fact]
public async Task Fail_tool_throws_with_the_server_content()
{
var ex = await Assert.ThrowsAsync<ToolExecutionException>(() => Run(new McpTool(Entry("fail"), _connection, false), new { }));
Assert.Equal("fake failure: nothing works", Assert.IsType<TextContent>(ex.Result.Content[0]).Text);
Assert.IsType<ImageContent>(ex.Result.Content[1]);
}
}

View File

@@ -0,0 +1,91 @@
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Extensions.Mcp;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class PlaywrightPresetTests
{
[Fact]
public void Default_argv_runs_the_pinned_package_headless_with_chromium_and_workspace_paths()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true });
Assert.Equal("npx", server.Command);
Assert.Equal(
["-y", PlaywrightPreset.DefaultPackage, "--headless", "--browser", "chromium",
"--output-dir", "${workspace}/browser", "--user-data-dir", "${workspace}/.volcanic/browser-profile"],
server.Args);
Assert.Equal("${workspace}", server.Cwd);
Assert.Null(server.ToolPrefix);
Assert.Equal(120, server.StartupTimeoutSeconds);
Assert.Equal(120, server.CallTimeoutSeconds);
Assert.Empty(server.Env);
}
[Fact]
public void Headed_browser_and_extra_args_are_passed_through()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Headless = false, Browser = "firefox", ExtraArgs = ["--no-sandbox", "--device", "iPhone 15"] });
Assert.DoesNotContain("--headless", server.Args);
Assert.Equal("firefox", server.Args[server.Args.IndexOf("--browser") + 1]);
Assert.Equal(["--no-sandbox", "--device", "iPhone 15"], server.Args.TakeLast(3));
}
[Fact]
public void Isolated_extra_arg_drops_the_profile_directory()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, ExtraArgs = ["--isolated"] });
Assert.DoesNotContain("--user-data-dir", server.Args);
Assert.Contains("--isolated", server.Args);
Assert.Contains("--output-dir", server.Args);
}
[Fact]
public void Package_override_replaces_the_pinned_spec()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Package = "@playwright/mcp@0.0.1" });
Assert.Equal(["-y", "@playwright/mcp@0.0.1"], server.Args.Take(2));
}
[Fact]
public void Command_runs_the_binary_directly_without_the_package()
{
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Command = "playwright-mcp", Package = "ignored@1.0.0" });
Assert.Equal("playwright-mcp", server.Command);
Assert.DoesNotContain("-y", server.Args);
Assert.DoesNotContain(server.Args, a => a.Contains('@'));
Assert.Equal("--headless", server.Args[0]);
}
[Fact]
public void Default_package_is_pinned_to_an_exact_version()
{
Assert.Matches(new Regex(@"^@playwright/mcp@\d+\.\d+\.\d+$"), PlaywrightPreset.DefaultPackage);
Assert.Equal("0.0.82", PlaywrightPreset.PackageVersion);
}
[Fact]
public void Describe_reports_enabled_and_disabled()
{
Assert.Equal("MCP: playwright enabled (headless, chromium)", PlaywrightPreset.Describe(new PlaywrightOptions { Enabled = true }));
Assert.Equal("MCP: playwright enabled (headed, firefox)", PlaywrightPreset.Describe(new PlaywrightOptions { Enabled = true, Headless = false, Browser = "firefox" }));
Assert.Equal("MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)", PlaywrightPreset.Describe(new PlaywrightOptions()));
Assert.Equal(PlaywrightPreset.Describe(new PlaywrightOptions()), new McpOptions().DescribePlaywright());
}
[Fact]
public void Prompt_section_mentions_send_only_when_asked()
{
var withSend = PlaywrightPreset.PromptSection(hasSend: true);
var without = PlaywrightPreset.PromptSection(hasSend: false);
Assert.StartsWith("## Browser", withSend, StringComparison.Ordinal);
Assert.Contains("call `send` with that", withSend, StringComparison.Ordinal);
Assert.DoesNotContain("`send`", without, StringComparison.Ordinal);
Assert.Contains("browser/", without, StringComparison.Ordinal);
Assert.Contains("browser_close", without, StringComparison.Ordinal);
Assert.Contains("passwords", without, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,64 @@
using System.Diagnostics;
using System.Text.Json;
using Volcanic.AgenticCore.Extensions.Mcp;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
/// <summary>Launch configs and log reading for the fake MCP server copied next to the tests.</summary>
internal static class FakeMcp
{
private const string AssemblyName = "Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer";
public static string DllPath => Path.Combine(AppContext.BaseDirectory, AssemblyName + ".dll");
/// <summary>The apphost, for configs that can't put the dll in argv (the Playwright preset's <c>Command</c>).</summary>
public static string AppHostPath => Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? AssemblyName + ".exe" : AssemblyName);
public static McpServerConfig Server(string mode = "normal", string? log = null, Action<McpServerConfig>? configure = null)
{
var config = new McpServerConfig
{
Command = "dotnet",
Args = [DllPath],
StartupTimeoutSeconds = 30,
CallTimeoutSeconds = 30,
IdleTimeoutSeconds = 0,
};
config.Env["FAKE_MCP_MODE"] = mode;
if (log is not null) config.Env["FAKE_MCP_LOG"] = log;
configure?.Invoke(config);
return config;
}
public static IReadOnlyList<JsonElement> ReadLog(string path)
{
if (!File.Exists(path)) return [];
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd().Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line => JsonSerializer.Deserialize<JsonElement>(line)).ToList();
}
public static IReadOnlyList<JsonElement> Starts(string path) =>
ReadLog(path).Where(e => e.GetProperty("event").GetString() == "start").ToList();
public static async Task WaitForAsync(Func<bool> condition, string what, int timeoutMs = 15_000)
{
var watch = Stopwatch.StartNew();
while (!condition())
{
if (watch.ElapsedMilliseconds > timeoutMs) throw new TimeoutException($"Timed out waiting for {what}.");
await Task.Delay(50);
}
}
public static bool HasExited(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return process.HasExited;
}
catch (ArgumentException) { return true; }
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
/// <summary>A session context outside a session: <see cref="PrepareProcessAsync"/> fills scope, id and zone like the core, then runs the policy.</summary>
internal sealed class McpStubContext(string cwd, IProcessSpawnPolicy policy) : IExtensionContext
{
public string Cwd { get; } = cwd;
public string SessionId => "mcp-test";
public bool HasUi => false;
public IUserInterface Ui => NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public TimeZoneInfo TimeZone { get; init; } = TimeZoneInfo.Utc;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
public List<ProcessSpawnRequest> Requests { get; } = [];
public ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken)
{
var filled = request with { Scope = "mcp-test", SessionId = SessionId, TimeZoneId = request.TimeZoneId ?? TimeZoneIds.ToIana(TimeZone) };
lock (Requests) Requests.Add(filled);
return ValueTask.FromResult(policy.Prepare(filled));
}
}

View File

@@ -0,0 +1,29 @@
using System.Collections;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
/// <summary>Injected host environments: a copy of the real one (so <c>dotnet</c> starts) without the variables the tests control.</summary>
internal static class McpTestEnvironment
{
private static readonly string[] Controlled = ["FAKE_MCP_MODE", "FAKE_MCP_LOG", "FAKE_MCP_DIE_MESSAGE", "OPENAI_API_KEY", "GITHUB_TOKEN", "VOLCANIC_AGENT_DIR", "TZ"];
public static Dictionary<string, string?> Snapshot(params (string Name, string Value)[] extra)
{
var env = new Dictionary<string, string?>(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
foreach (DictionaryEntry e in Environment.GetEnvironmentVariables()) env[(string)e.Key] = (string?)e.Value;
foreach (var name in Controlled) env.Remove(name);
foreach (var (name, value) in extra) env[name] = value;
return env;
}
public static ProcessSpawnPolicy Policy(IReadOnlyDictionary<string, string?> env, Action<ProcessEnvironmentOptions>? configure = null)
{
var options = new ProcessEnvironmentOptions { HardenHostProcess = false };
configure?.Invoke(options);
return new ProcessSpawnPolicy(options, null, () => env);
}
public static Func<string, string?> Lookup(IReadOnlyDictionary<string, string?> env) =>
name => env.TryGetValue(name, out var value) ? value : null;
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
/// <summary>
/// A core host with the FauxProvider and the MCP extension over an injected host environment: the policy and the
/// extension's <c>${env:…}</c> lookup both read <see cref="HostEnvironment"/>, and the catalogue cache is private to the host.
/// </summary>
internal sealed class McpTestHost : IAsyncDisposable
{
private readonly ServiceProvider _services;
private readonly string _root = Path.Combine(Path.GetTempPath(), "volcanic-mcp-host", Guid.NewGuid().ToString("N"));
private readonly List<AgentSession> _sessions = [];
public McpTestHost(McpOptions options, Action<Dictionary<string, string?>>? environment = null, IEnumerable<string>? secretValues = null, params IExtension[] extra)
: this(_ => options, environment, secretValues, extra) { }
/// <summary><paramref name="options"/> runs once the host's paths exist, so configs can point at <see cref="LogPath"/>.</summary>
public McpTestHost(Func<McpTestHost, McpOptions> options, Action<Dictionary<string, string?>>? environment = null, IEnumerable<string>? secretValues = null, params IExtension[] extra)
{
Directory.CreateDirectory(Workspace);
Directory.CreateDirectory(AgentDir);
HostEnvironment = McpTestEnvironment.Snapshot(("VOLCANIC_AGENT_DIR", AgentDir));
environment?.Invoke(HostEnvironment);
var cache = new McpCatalogCache(Time);
var lookup = McpTestEnvironment.Lookup(HostEnvironment);
var processOptions = new ProcessEnvironmentOptions { HardenHostProcess = false };
foreach (var secret in secretValues ?? []) processOptions.SecretValues.Add(secret);
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(Logs);
services.AddSingleton<IProcessSpawnPolicy>(new ProcessSpawnPolicy(processOptions, null, () => HostEnvironment));
services.AddAgenticCore(o =>
{
o.AgentDir = AgentDir;
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.ProcessEnvironment = processOptions;
o.SessionStoreFactory = _ => new InMemorySessionStore();
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
var mcpOptions = options(this);
o.Extensions.Add(new ExtensionRegistration("mcp", _ => new McpExtension(mcpOptions, cache, lookup, Time), "test"));
foreach (var e in extra) o.Extensions.Add(ExtensionRegistration.FromInstance(e));
});
_services = services.BuildServiceProvider();
}
public FauxProvider Faux { get; } = new();
public FakeTimeProvider Time { get; } = new();
public RecordingLoggerFactory Logs { get; } = new();
public Dictionary<string, string?> HostEnvironment { get; }
public string Workspace => Path.Combine(_root, "ws");
public string AgentDir => Path.Combine(_root, "agent");
public string LogPath => Path.Combine(_root, "fake.log");
public async Task<AgentSession> CreateSessionAsync(TimeZoneInfo? zone = null)
{
var session = await _services.GetRequiredService<IAgentSessionFactory>()
.CreateAsync(new AgentSessionOptions { Cwd = Workspace, TimeZone = zone });
lock (_sessions) _sessions.Add(session);
return session;
}
public async ValueTask DisposeAsync()
{
foreach (var session in _sessions) await session.DisposeAsync();
await _services.DisposeAsync();
try { Directory.Delete(_root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
/// <summary>Captures formatted log lines at every level.</summary>
public sealed class RecordingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
public bool Has(LogLevel level, string fragment)
{
lock (Entries) return Entries.Any(e => e.Level == level && e.Message.Contains(fragment, StringComparison.Ordinal));
}
public IReadOnlyList<string> Messages(LogLevel level)
{
lock (Entries) return Entries.Where(e => e.Level == level).Select(e => e.Message).ToList();
}
}
/// <summary>Every category logs into one <see cref="RecordingLogger"/>.</summary>
public sealed class RecordingLoggerFactory : ILoggerFactory
{
public RecordingLogger Logger { get; } = new();
public ILogger CreateLogger(string categoryName) => Logger;
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}

View File

@@ -0,0 +1,43 @@
using Volcanic.AgenticCore.Extensions.Mcp;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class ToolNamingTests
{
[Fact]
public void Valid_names_are_unchanged() => Assert.Equal("browser_navigate", ToolNaming.Sanitize(null, "browser_navigate"));
[Fact]
public void Invalid_characters_become_underscores()
{
Assert.Equal("weird_name_x", ToolNaming.Sanitize(null, "weird.name/x"));
Assert.Equal("a_b_c-d", ToolNaming.Sanitize(null, "a b:c-d"));
}
[Fact]
public void Prefix_is_prepended() => Assert.Equal("gh_create_issue", ToolNaming.Sanitize("gh_", "create_issue"));
[Fact]
public void Names_are_truncated_to_48_characters()
{
Assert.Equal(new string('a', 48), ToolNaming.Sanitize(null, new string('a', 60)));
var prefixed = ToolNaming.Sanitize("p_", new string('b', 60));
Assert.Equal(48, prefixed.Length);
Assert.StartsWith("p_bbb", prefixed, StringComparison.Ordinal);
}
[Fact]
public void Duplicates_get_numeric_suffixes_within_the_limit()
{
Assert.Equal(["weird_name_x", "weird_name_x_2", "weird_name_x_3"],
ToolNaming.Expose(["weird.name/x", "weird_name_x", "weird:name:x"], null));
var longNames = ToolNaming.Expose([new string('a', 50), new string('a', 49)], null);
Assert.Equal(new string('a', 48), longNames[0]);
Assert.Equal(new string('a', 46) + "_2", longNames[1]);
Assert.All(longNames, n => Assert.True(n.Length <= ToolNaming.MaxLength));
}
[Fact]
public void Empty_name_falls_back_to_tool() => Assert.Equal("tool", ToolNaming.Sanitize(null, ""));
}

View File

@@ -10,6 +10,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<None Include="ClaudeCode/Fixtures/*" CopyToOutputDirectory="PreserveNewest" />
@@ -21,5 +22,7 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,70 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.Mcp;
namespace Volcanic.AgenticCore.Telegram.Tests;
public class McpConfigurationTests
{
[Fact]
public void Mcp_section_binds_servers_and_the_playwright_preset()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Mcp:Servers:github:Command"] = "npx",
["Mcp:Servers:github:Args:0"] = "-y",
["Mcp:Servers:github:Args:1"] = "@modelcontextprotocol/server-github@2025.9.1",
["Mcp:Servers:github:Env:GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}",
["Mcp:Servers:github:ToolPrefix"] = "gh_",
["Mcp:Servers:github:IdleTimeoutSeconds"] = "0",
["Mcp:Servers:github:StderrLevel"] = "Information",
["Mcp:Playwright:Enabled"] = "true",
["Mcp:Playwright:Headless"] = "false",
["Mcp:Playwright:ExtraArgs:0"] = "--isolated",
}).Build();
var mcp = config.GetSection(McpOptions.SectionName).Get<McpOptions>()!;
var github = mcp.Servers["github"];
Assert.Equal("npx", github.Command);
Assert.Equal(["-y", "@modelcontextprotocol/server-github@2025.9.1"], github.Args);
Assert.Equal("${env:GITHUB_TOKEN}", github.Env["GITHUB_TOKEN"]);
Assert.Equal("gh_", github.ToolPrefix);
Assert.Equal(0, github.IdleTimeoutSeconds);
Assert.Equal(LogLevel.Information, github.StderrLevel);
Assert.Equal(120, github.CallTimeoutSeconds);
Assert.True(mcp.Playwright.IsEnabled);
Assert.False(mcp.Playwright.IsHeadless);
Assert.Equal(["--isolated"], mcp.Playwright.ExtraArgs!);
Assert.Empty(mcp.Validate());
Assert.Equal("MCP: playwright enabled (headed, chromium)", mcp.DescribePlaywright());
}
[Fact]
public void Bound_servers_arrive_in_name_order_whatever_the_file_order()
{
// Microsoft.Extensions.Configuration sorts child keys, so the file order is lost; McpServerSet.Resolve then
// takes host servers by name and the first by name wins a tool name.
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Mcp:Servers:zeta:Command"] = "zeta-mcp",
["Mcp:Servers:alpha:Command"] = "alpha-mcp",
}).Build();
var mcp = config.GetSection(McpOptions.SectionName).Get<McpOptions>()!;
Assert.Equal(["alpha", "zeta"], mcp.Servers.Keys);
}
[Fact]
public void Configured_values_win_over_PLAYWRIGHT_variables()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Mcp:Playwright:Enabled"] = "false" }).Build();
var mcp = config.GetSection(McpOptions.SectionName).Get<McpOptions>()!;
mcp.ApplyEnvironment(name => name == "PLAYWRIGHT_ENABLED" ? "true" : null);
Assert.False(mcp.Playwright.IsEnabled);
Assert.Equal("MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)", mcp.DescribePlaywright());
}
}

View File

@@ -0,0 +1,36 @@
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class ToolExecutionExceptionTests
{
[Fact]
public void Lives_in_the_abstractions_assembly()
{
Assert.Equal(typeof(ITool).Assembly, typeof(ToolExecutionException).Assembly);
Assert.Equal("Volcanic.AgenticCore.Abstractions.Tools", typeof(ToolExecutionException).Namespace);
}
public sealed record NoArgs;
[Fact]
public async Task Extension_tool_throwing_it_yields_an_error_result_with_its_content_and_details()
{
var tool = new DelegateTool<NoArgs>("failing", "fails", (_, _, _) =>
throw new ToolExecutionException(new ToolResult([new TextContent("server said no")], VolcanicJson.ToElement(new { code = 7 }))));
await using var host = new TestHost(null, new LambdaExtension("tools", api => { api.RegisterTool(tool); return ValueTask.CompletedTask; }));
host.Faux.Enqueue(FauxTurn.Tool("failing", new { }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.True(result.IsError);
Assert.Equal("server said no", result.Text);
Assert.Equal(7, result.Details!.Value.GetProperty("code").GetInt32());
}
}