Merge branch 'feat/mcp-client-playwright'
This commit is contained in:
@@ -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`)
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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`:
|
||||
|
||||
```
|
||||
|
||||
@@ -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
132
docs/mcp.md
Normal 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.
|
||||
4461
docs/superpowers/plans/2026-09-26-mcp-client-playwright.md
Normal file
4461
docs/superpowers/plans/2026-09-26-mcp-client-playwright.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
24
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpCatalog.cs
Normal file
24
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpCatalog.cs
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpCommand.cs
Normal file
80
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpCommand.cs
Normal 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 <name></c> details, <c>/mcp restart <name></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];
|
||||
}
|
||||
156
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs
Normal file
156
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs
Normal 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; } = [];
|
||||
}
|
||||
64
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpLaunch.cs
Normal file
64
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpLaunch.cs
Normal 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();
|
||||
}
|
||||
119
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpOptions.cs
Normal file
119
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpOptions.cs
Normal 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 <package></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;
|
||||
}
|
||||
103
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpProcess.cs
Normal file
103
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpProcess.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <name></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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
49
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpTool.cs
Normal file
49
extensions/Volcanic.AgenticCore.Extensions.Mcp/McpTool.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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><agent dir>/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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
43
extensions/Volcanic.AgenticCore.Extensions.Mcp/ShellWords.cs
Normal file
43
extensions/Volcanic.AgenticCore.Extensions.Mcp/ShellWords.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
27
extensions/Volcanic.AgenticCore.Extensions.Mcp/StderrTail.cs
Normal file
27
extensions/Volcanic.AgenticCore.Extensions.Mcp/StderrTail.cs
Normal 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]; } }
|
||||
}
|
||||
40
extensions/Volcanic.AgenticCore.Extensions.Mcp/ToolNaming.cs
Normal file
40
extensions/Volcanic.AgenticCore.Extensions.Mcp/ToolNaming.cs
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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, ""));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user