diff --git a/CLAUDE.md b/CLAUDE.md index 5a9792f..8d9f329 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,10 @@ tasks by push, subscribe or poll, persists them in `.volcanic/a2a/pending.json` - 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`. + `Mcp/Remote/RemoteMcpExtension` is separate: remote servers (Streamable HTTP/SSE) per chat from the workspace's + `.volcanic/mcp/servers.json`, headers sealed (AES-GCM, chat + visible fields as associated data) with a host secret + so only the owner's `/mcp_add|remove|tools|list` change them; tools `__` are registered and + (de)activated in the live session; tests use an in-memory ASP.NET MCP server (`Mcp/Remote/RemoteMcpTestHost`). ### Telegram host (`hosts/Volcanic.AgenticCore.Telegram`) @@ -134,6 +138,8 @@ writes there. Each private chat's `trigger` tool manages its topic triggers (`Tr `noul` question per trigger, 3 retries) about every message and logs one line per message; matches are batched for 10 s by `TriggerDispatcher` and delivered as follow-up prompts (`TelegramTriggerDelivery`); snoozed triggers queue messages (`SnoozeQueue`) and check them as one batch when the snooze ends. `/triggers` lists them. +`RemoteMcpSetup` adds `RemoteMcpExtension` to chat sessions (secret `MCP_ENCRYPTION_SECRET`, else the bot token); the +router deletes a `/mcp_add` carrying a key and any answer to a `UiRequestOptions.Secret` question. `ADMIN_USER_ID` (`Telegram:AdminUserId`) may `/impersonate ` (`Chats/Impersonation`, in memory, `0` stops): the router then handles the admin's updates as the target's, and the target chat's renderer delivers to the admin's chat (`ChatRenderer.DeliveryChatId`); the target's own updates get `Impersonation.BlockedNotice` instead. Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`. diff --git a/README.md b/README.md index 3d655cd..44cbfb1 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Claude Code as a model provider without an API key. | Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge | | Google Calendar (tool + connect commands) | `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`; one account per chat | | Scheduler (scheduled prompts, watcher scripts) | `extensions/Volcanic.AgenticCore.Extensions.Scheduler` | Cronos; per-chat jobs in `/scheduler/jobs.json`; see [docs/telegram-bot.md](docs/telegram-bot.md#scheduling) | -| MCP client (stdio servers as tools, Playwright browser preset) | `extensions/Volcanic.AgenticCore.Extensions.Mcp` | See [docs/mcp.md](docs/mcp.md) | +| MCP client (stdio servers as tools, Playwright browser preset, remote HTTP/SSE servers per chat) | `extensions/Volcanic.AgenticCore.Extensions.Mcp` | See [docs/mcp.md](docs/mcp.md) | | A2A client (`a2a` tool, watches for long remote tasks) | `extensions/Volcanic.AgenticCore.Extensions.A2A` | `A2AClient__Enabled`; agents in `/.volcanic/a2a/agents.json`; see [docs/a2a.md](docs/a2a.md#client) | | A2A server (other agents call the assistant) | `src/Volcanic.AgenticCore.A2A.Server` | `A2A__Enabled`; callers in `/.volcanic/a2a/callers.json`; see [docs/a2a.md](docs/a2a.md) | | Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list | diff --git a/docker-compose.yml b/docker-compose.yml index 3367506..2ef3194 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - TRIGGER_MODEL=${TRIGGER_MODEL:-} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec) - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - MCP_ENCRYPTION_SECRET=${MCP_ENCRYPTION_SECRET:-} - ADMIN_USER_ID=${ADMIN_USER_ID:-} # may /impersonate other users (telegram-bot.md) - DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ - PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN diff --git a/docs/docker.md b/docs/docker.md index 7c4a347..93bc7f7 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -82,6 +82,7 @@ The compose file maps them into the container. | `TRIGGER_MODEL` | `typesafe/jev-1.13` | The Jev model triggers use | Yes | | `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes | | `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted | +| `MCP_ENCRYPTION_SECRET` | — | Seals each chat's remote MCP keys; without it the bot token does ([mcp.md](mcp.md#remote-servers-per-chat)) | No, and redacted | | `A2A_ENABLED` | `false` | Serves other agents over A2A on port 8080 ([a2a.md](a2a.md), [telegram-bot.md#a2a](telegram-bot.md#a2a)); passed as `A2A__Enabled`. Needs a domain in Coolify (see below) | No | | `A2A_PUBLIC_URL` | — | External base URL for the agent card and push webhooks, e.g. `https://bot.example.com`; passed as `A2A__PublicUrl`. Required for push notifications to work | No | | `A2A_CLIENT_ENABLED` | `false` | Lets each chat's assistant call other A2A agents (the `a2a` tool, agents in the chat's `.volcanic/a2a/agents.json`; [a2a.md](a2a.md#client)); passed as `A2AClient__Enabled`. Independent of `A2A_ENABLED`; needs no port or domain, only outbound HTTPS | No | diff --git a/docs/mcp.md b/docs/mcp.md index b75b528..29bc6b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,7 +1,8 @@ # 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` +tools to the agent as ordinary tools. Its second extension, `RemoteMcpExtension`, connects each chat to its own remote +servers over HTTP ([below](#remote-servers-per-chat)). 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 @@ -112,6 +113,61 @@ tools appear as `mcp__volcanic__`. 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`). +## Remote servers per chat + +`RemoteMcpExtension` (`mcp-remote`, in `Remote/`) connects a chat to remote MCP servers over **Streamable HTTP**, or +the older **SSE** transport. It never starts a process. The Telegram host adds it to every private chat session (not +to A2A server sessions). + +### The file + +`/.volcanic/mcp/servers.json`, written only by the commands below: + +```json +{ + "version": 1, + "servers": [ + { "name": "notes", "url": "https://mcp.example.com/mcp", "transport": "auto", "tools": ["search", "get"], + "auth": "headers", "sealed": "" } + ] +} +``` + +- `name`: 1–20 characters of `a-z`, `0-9`, `-`. `url`: absolute http(s) without user info. `transport`: `auto` (Streamable + HTTP, falling back to SSE), `http` or `sse`. `tools`: `null` for every tool, else an allowlist of the server's names. +- `sealed` holds `{"headers": {...}}` encrypted with AES-256-GCM. The key is derived (HKDF-SHA256) from a host secret + the agent's processes never see (Telegram: `MCP_ENCRYPTION_SECRET`, else the bot token). The chat's session scope and + every visible field are the associated data. An entry written or edited by anyone without the secret (the agent's + own `bash`, for example) or copied from another chat therefore doesn't open. It is skipped, and `/mcp_list` and the + agent's system prompt say so. The agent can delete the file, but it can't add or redirect a server. +- `auth` is `headers` today. OAuth would be another value whose tokens live in the same sealed part. +- A new secret makes every entry unreadable: add the servers again. + +### Commands (no model call; nothing enters the conversation) + +| Command | | +|---|---| +| `/mcp_list` | Each server: url without query, transport, status (`connecting`, `connected · N tools`, `unavailable: `), header names only, allowlist, active tools; then skipped entries | +| `/mcp_add [--tools a,b] [--transport auto\|http\|sse] [--bearer ] [--header "Name: value"]... [--no-key] [--force]` | Without a key option the bot asks for the key in a separate message (`UiRequestOptions.Secret`; Telegram deletes the answer). A bare token becomes `Authorization: Bearer `; `Name: value` lines set any headers; `-` means none. The server is tried first (handshake and `tools/list`); a failure is reported and nothing is saved unless `--force`. The same name replaces the old entry | +| `/mcp_remove ` | Deletes the entry; its tools are deactivated at once | +| `/mcp_tools [a,b\|all]` | Without a list: offered and allowed tools. With one: sets the allowlist | + +Telegram also deletes a `/mcp_add` message that contains `--bearer` or `--header`. + +### Tools and lifecycle + +- The agent sees `__`, sanitised and capped at 48 characters like the stdio tools (through the Claude Code + provider: `mcp__volcanic__notes__search`). A name that some other tool already has is skipped with a warning. +- When the session starts, every server is connected in the background (handshake plus `tools/list`, at most 15 s each). + The first run waits for those connects, and tools are added to the live session; no restart or `/new` is needed, + after the commands either. Calls run one at a time per tool, up to 120 s each; `/stop` sends `notifications/cancelled`. +- An unreachable server doesn't block the session: it has no tools, and the system prompt's "Remote MCP servers" section + tells the agent which server is unavailable and why ("401 (Unauthorized)", "no answer within 15s", ...). It is retried + before later runs, after 1, 2, 4 ... up to 15 minutes. A call that fails in the transport drops the connection; the + next call reconnects. Failed calls are never retried, since the server may have run them. +- Header values (and the credential after `Bearer `) are replaced with `[REDACTED]` in every tool result of the chat, + in errors and in command replies, so a server that echoes the key doesn't leak it. + ## `/mcp` | Command | | diff --git a/docs/superpowers/specs/2026-10-09-remote-mcp-per-chat-design.md b/docs/superpowers/specs/2026-10-09-remote-mcp-per-chat-design.md new file mode 100644 index 0000000..358dc85 --- /dev/null +++ b/docs/superpowers/specs/2026-10-09-remote-mcp-per-chat-design.md @@ -0,0 +1,43 @@ +# Remote MCP servers per chat — design + +## Goal + +Let a chat's owner connect their own remote MCP servers (Streamable HTTP, SSE fallback) to that chat's agent. No stdio, +no processes. The agent must not be able to add or redirect servers, keys must not reach the model, and an +unreachable server must not stop the session. + +## Decisions + +- **Separate extension** `RemoteMcpExtension` (`mcp-remote`) in the existing Mcp assembly, reusing `ToolNaming`, + `McpResultMapper` and `McpTool.NormalizeSchema`. The stdio `McpExtension` is unchanged. +- **Storage**: `/.volcanic/mcp/servers.json`, like the A2A `agents.json`, but each entry's headers are sealed + with AES-256-GCM. The key comes from a host secret via HKDF (`MCP_ENCRYPTION_SECRET`, else the bot token; both are on + the deny-list). The associated data is the chat's session scope plus every visible field (name, url, transport, + tools, auth). This makes the seal an authenticity check: the agent can write to the workspace but can't create, + redirect or move an entry, so only the commands add servers. The OAuth case is covered by `auth` (`headers` today) + plus the sealed part. +- **Commands only** (`/mcp_list`, `/mcp_add`, `/mcp_remove`, `/mcp_tools`). Commands never enter the conversation. The + key comes inline or as the answer to a `UiRequestOptions.Secret` question, which the Telegram UI deletes from the + chat; Telegram also deletes a `/mcp_add` message that carries `--bearer`/`--header`. The commands are registered even + without a secret, so a `/mcp_add … --bearer …` never falls through to the model. +- **Live tools**: the registry allows registration after activation but no removal. One `RemoteMcpTool` per exposed + name is therefore re-bound (description, schema, connection) on reconnect or re-add, and unbound plus deactivated on + removal. Names: `__`. `mcp__` is avoided because the Claude Code bridge already prefixes + `mcp__volcanic__`. +- **Lifecycle**: `SessionStart` reconciles with the file and connects in the background. `BeforeAgentStart` reconciles + again (this picks up a deleted file), waits for connects in flight (each ≤ `ConnectTimeout`, 15 s), retries failed + servers with backoff (1 min doubling to 15 min), and writes a "Remote MCP servers" prompt section. That section names + unavailable servers and why, which is how the agent learns about an error. A transport failure during a call drops + the client, and the next call reconnects. Calls are not retried. +- **Redaction**: a `ToolResultEvent` handler replaces header values (and the credential after a scheme) in every tool + result of the chat. Errors and command replies go through the same redactor. + +## Limits + +- Only header auth; no OAuth flow yet. +- The agent can delete `servers.json` (dropping servers) or restore an older copy of it that it saved earlier (bringing + back a removed server). It can't forge or alter an entry. +- The key is visible in Telegram until the bot deletes the message (a moment), and Telegram keeps it on its servers + until then. +- A changed tool list on the server is picked up on reconnect (a new session after a restart, or `/mcp_add` again), not + live (`notifications/tools/list_changed` is ignored). diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 6fdab8d..f561f14 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -68,6 +68,7 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section | `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:` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) | +| `MCP_ENCRYPTION_SECRET` | the bot token | Seals the keys of each chat's remote MCP servers ([mcp.md](mcp.md#remote-servers-per-chat)). Set it so a new bot token doesn't make the stored servers unreadable. Hidden from `bash` and redacted | | `A2A__Enabled` / `A2A:Enabled` | `false` | Serve other agents over A2A (see [A2A](#a2a) and [a2a.md](a2a.md)). Opens Kestrel: port 8080 in the container, `ASPNETCORE_URLS` or Kestrel's default (`http://localhost:5000`) under `dotnet run` | | `A2A__PublicUrl` / `A2A:PublicUrl` | — | External base URL for the agent card and push webhooks, e.g. `https://bot.example.com`. Required for push to work; see [docker.md](docker.md) | | `A2AClient__Enabled` / `A2AClient:Enabled` | `false` | The A2A client: the `a2a` tool in every allowed chat, for calling other agents listed in the chat's `.volcanic/a2a/agents.json`, and watches for their long tasks (see [A2A](#a2a) and [a2a.md](a2a.md#client)). Independent of `A2A__Enabled` | @@ -127,6 +128,10 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and | `/calendar_status` | The connected account and time zone, or the pending code | | `/calendar_disconnect` | Revoke and delete this chat's calendar token, or cancel a pending connect | | `/a2a` | This chat's A2A callers and recent tasks, remote agents and watches (see [A2A](#a2a)) | +| `/mcp_list` | This chat's remote MCP servers: status, header names (never values), allowed and active tools | +| `/mcp_add ` | Add a remote MCP server; the bot asks for the key and deletes your answer (see [Remote MCP servers](#remote-mcp-servers)) | +| `/mcp_remove ` | Remove one; its tools disappear at once | +| `/mcp_tools [a,b\|all]` | Show a server's tools, or limit which ones the agent gets | Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` and `/claude-code-login` work. `/mcp` lists the MCP servers (see [mcp.md](mcp.md#mcp)). @@ -299,6 +304,22 @@ chat's account, so nobody sees another chat's calendar through the agent. The bo sending the link and code and confirms in the chat once access is approved. Setup of the Google Cloud app, token storage and time zones are in [google-calendar.md](google-calendar.md). +### Remote MCP servers + +Each chat can connect its owner's remote MCP servers (Streamable HTTP or SSE; no processes are started): + +``` +/mcp_add notes https://mcp.example.com/mcp +→ Key for notes: send the token (sent as "Authorization: Bearer "), or one "Header-Name: value" per line, or - for none. +tok_abc123 ← your reply; the bot deletes it and the agent never sees it +→ Added notes (https://mcp.example.com/mcp): 12 tools on the server. +``` + +Options: `--tools a,b` (allowlist), `--transport auto|http|sse`, `--bearer ` or `--header "Name: value"` instead +of the question (the bot deletes that message too), `--no-key`, `--force` (save even when the server can't be reached +now). The tools appear in the same conversation as `notes__`. Only these commands change the servers: the agent +can't add one, even by editing `.volcanic/mcp/servers.json`. Details in [mcp.md](mcp.md#remote-servers-per-chat). + ### A2A With `A2A__Enabled=true` the bot also serves [A2A](a2a.md): other agents can call it over HTTP once given a key. diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpCommands.cs b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpCommands.cs new file mode 100644 index 0000000..01d6325 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpCommands.cs @@ -0,0 +1,231 @@ +using System.Text; +using System.Text.RegularExpressions; +using Volcanic.AgenticCore.Abstractions.Extensions; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Ui; + +namespace Volcanic.AgenticCore.Extensions.Mcp.Remote; + +/// +/// The owner's commands. Commands never enter the conversation, so a key given here (inline or in answer to the key +/// question) never reaches the model. No model call is made. +/// +internal static partial class RemoteMcpCommands +{ + public const string List = "mcp_list"; + public const string Add = "mcp_add"; + public const string Remove = "mcp_remove"; + public const string Tools = "mcp_tools"; + + public const string AddUsage = + "Usage: /mcp_add [--tools a,b] [--transport auto|http|sse] [--bearer ] [--header \"Name: value\"] [--no-key] [--force]\n" + + "Without --bearer, --header or --no-key I ask for the key in a separate message."; + + internal const string DisabledReply = + "Remote MCP servers are off on this bot: the host needs an encryption secret for the keys (MCP_ENCRYPTION_SECRET)."; + + public static void Register(IExtensionApi api, RemoteMcpExtension extension) + { + api.RegisterCommand(List, Command(api, List, "This chat's remote MCP servers", (_, _, _) => Task.FromResult(ListText(extension)))); + api.RegisterCommand(Add, Command(api, Add, "Add a remote MCP server: /mcp_add ", (args, ctx, ct) => AddAsync(extension, args, ctx, ct))); + api.RegisterCommand(Remove, Command(api, Remove, "Remove a remote MCP server: /mcp_remove ", (args, ctx, _) => RemoveAsync(extension, args, ctx))); + api.RegisterCommand(Tools, Command(api, Tools, "Show or limit a server's tools: /mcp_tools [a,b|all]", (args, ctx, _) => ToolsAsync(extension, args, ctx))); + } + + private static CommandDefinition Command(IExtensionApi api, string name, string description, + Func> run) => new(description, async (args, ctx, ct) => + { + string text; + try + { + text = await run(args, ctx, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + text = $"Could not update {RemoteServersFile.RelativePath}: {ex.Message}"; + } + if (ctx.HasUi) ctx.Ui.Notify(text); + else api.SendMessage(CustomMessage.FromText(name, text), MessageDelivery.Steer, triggerTurn: false); + }); + + internal static string ListText(RemoteMcpExtension extension) + { + if (!extension.IsConfigured) return DisabledReply; + var statuses = extension.Statuses(); + var problems = extension.FileProblems; + if (statuses.Count == 0 && problems.Count == 0) return "No remote MCP servers in this chat. Add one with /mcp_add ."; + var sb = new StringBuilder("**Remote MCP servers**"); + foreach (var s in statuses) + { + var e = s.Entry; + sb.Append($"\n• **{e.Name}** {RemoteServersFile.DisplayUrl(e.Url)} ({RemoteServersFile.TransportName(e.Transport)}): {s.State}"); + sb.Append(e.Headers.Count == 0 ? "\n no key" : $"\n headers: {string.Join(", ", e.Headers.Keys)} (values hidden)"); + if (e.Tools is { } allowed) sb.Append($"\n allowed tools: {string.Join(", ", allowed)}"); + if (s.ExposedTools.Count > 0) sb.Append($"\n tools: {string.Join(", ", s.ExposedTools)}"); + } + foreach (var problem in problems) sb.Append($"\n• skipped: {problem}"); + return extension.Redact(sb.ToString()); + } + + internal static async Task AddAsync(RemoteMcpExtension extension, string args, IExtensionCommandContext ctx, CancellationToken cancellationToken) + { + if (!extension.IsConfigured) return DisabledReply; + IReadOnlyList words; + try { words = ShellWords.Split(args); } + catch (FormatException ex) { return $"{ex.Message}\n{AddUsage}"; } + + var positional = new List(); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + IReadOnlyList? tools = null; + var transport = RemoteTransport.Auto; + bool noKey = false, force = false; + for (var i = 0; i < words.Count; i++) + { + var word = words[i]; + string? Next() => i + 1 < words.Count ? words[++i] : null; + switch (word) + { + case "--tools": + if (Next() is not { } list) return AddUsage; + tools = ParseToolList(list); + break; + case "--transport": + if (!RemoteServersFile.TryTransport(Next(), out transport)) return "--transport must be auto, http or sse."; + break; + case "--bearer": + if (Next() is not { Length: > 0 } token) return AddUsage; + headers["Authorization"] = $"Bearer {token}"; + break; + case "--header": + if (Next() is not { } header || ParseHeader(header) is not { } parsed) return "--header takes \"Name: value\"."; + headers[parsed.Name] = parsed.Value; + break; + case "--no-key": noKey = true; break; + case "--force": force = true; break; + default: + if (word.StartsWith("--", StringComparison.Ordinal)) return $"Unknown option {word}.\n{AddUsage}"; + positional.Add(word); + break; + } + } + if (positional.Count != 2) return AddUsage; + var (name, url) = (positional[0], positional[1]); + if (!RemoteServersFile.IsValidName(name)) + return $"The name must be 1–{RemoteServersFile.MaxNameLength} characters of a-z, 0-9 and '-', starting with a letter or digit."; + if (RemoteServersFile.CheckUrl(url) is { } urlProblem) return $"Not added: {urlProblem}."; + + if (headers.Count == 0 && !noKey) + { + if (!ctx.HasUi) return "Pass the key with --bearer or --header, or --no-key for a server without one."; + var answer = await ctx.Ui.InputAsync( + $"Key for {name}: send the token (sent as \"Authorization: Bearer \"), or one \"Header-Name: value\" per line, or - for none. " + + "The assistant never sees this message, and I delete it once read.", + "token", new UiRequestOptions(extension.Options.KeyPromptTimeout, Secret: true), cancellationToken).ConfigureAwait(false); + if (answer is null) return $"No key received; {name} was not added."; + if (ParseKeyAnswer(answer, headers) is { } keyProblem) return $"{keyProblem} {name} was not added."; + } + + var entry = new RemoteServerEntry(name, url, transport, tools, headers); + var cipher = extension.CipherFor(ctx); + var probe = new RemoteServerConnection(entry, extension.Options); + string tested; + try + { + var offered = await probe.ConnectAsync(cancellationToken).ConfigureAwait(false); + var unknown = tools?.Where(t => offered.All(o => o.Name != t)).ToList() ?? []; + tested = $"{offered.Count} tools on the server" + (unknown.Count > 0 ? $"; not offered by it: {string.Join(", ", unknown)}" : ""); + } + catch (RemoteServerException ex) + { + if (!force) return extension.Redact($"Not added: {ex.Message}. Check the url and key, or add --force to save it anyway."); + tested = $"not reachable now ({ex.Message}); saved anyway"; + } + finally + { + await probe.DisposeAsync().ConfigureAwait(false); + } + + var current = RemoteServersFile.Read(ctx.Cwd, cipher).Servers.ToList(); + var replaced = current.RemoveAll(s => s.Name == name) > 0; + current.Add(entry); + RemoteServersFile.Write(ctx.Cwd, cipher, current); + await extension.SyncAsync(wait: true).ConfigureAwait(false); + var state = extension.Statuses().FirstOrDefault(s => s.Entry.Name == name); + var exposed = state?.ExposedTools is { Count: > 0 } activeTools ? $"\nTools for the assistant: {string.Join(", ", activeTools)}" : ""; + return extension.Redact($"{(replaced ? "Replaced" : "Added")} {name} ({RemoteServersFile.DisplayUrl(url)}): {tested}.{exposed}"); + } + + internal static async Task RemoveAsync(RemoteMcpExtension extension, string args, IExtensionCommandContext ctx) + { + if (!extension.IsConfigured) return DisabledReply; + var name = args.Trim(); + if (name.Length == 0) return "Usage: /mcp_remove "; + var cipher = extension.CipherFor(ctx); + var read = RemoteServersFile.Read(ctx.Cwd, cipher); + var remaining = read.Servers.Where(s => s.Name != name).ToList(); + if (remaining.Count == read.Servers.Count) return $"No server named {name} in this chat."; + RemoteServersFile.Write(ctx.Cwd, cipher, remaining); + await extension.SyncAsync(wait: false).ConfigureAwait(false); + return $"Removed {name}; its tools are gone from this chat."; + } + + internal static async Task ToolsAsync(RemoteMcpExtension extension, string args, IExtensionCommandContext ctx) + { + if (!extension.IsConfigured) return DisabledReply; + var parts = args.Trim().Split((char[]?)null, 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) return "Usage: /mcp_tools [tool1,tool2|all]"; + var name = parts[0]; + var cipher = extension.CipherFor(ctx); + var servers = RemoteServersFile.Read(ctx.Cwd, cipher).Servers.ToList(); + var index = servers.FindIndex(s => s.Name == name); + if (index < 0) return $"No server named {name} in this chat."; + if (parts.Length == 1) + { + var status = extension.Statuses().FirstOrDefault(s => s.Entry.Name == name); + var offered = status?.AllTools is { } all ? string.Join(", ", all.Select(t => t.Name)) : "unknown (the server is not connected)"; + var allowed = servers[index].Tools is { } list ? string.Join(", ", list) : "all"; + return $"**{name}**\nOffered: {offered}\nAllowed: {allowed}\nChange with /mcp_tools {name} a,b or /mcp_tools {name} all."; + } + var tools = parts[1].Equals("all", StringComparison.OrdinalIgnoreCase) ? null : ParseToolList(parts[1]); + servers[index] = servers[index] with { Tools = tools }; + RemoteServersFile.Write(ctx.Cwd, cipher, servers); + await extension.SyncAsync(wait: true).ConfigureAwait(false); + var exposed = extension.Statuses().FirstOrDefault(s => s.Entry.Name == name)?.ExposedTools ?? []; + return $"{name}: {(tools is null ? "all tools allowed" : $"allowed {string.Join(", ", tools)}")}. Active now: {(exposed.Count == 0 ? "none" : string.Join(", ", exposed))}."; + } + + internal static IReadOnlyList ParseToolList(string text) => + [.. text.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct(StringComparer.Ordinal)]; + + /// "Name: value" with a valid header name; null otherwise. + internal static (string Name, string Value)? ParseHeader(string text) + { + var match = HeaderLine().Match(text.Trim()); + if (!match.Success || !RemoteServersFile.IsValidHeaderName(match.Groups[1].Value)) return null; + var value = match.Groups[2].Value.Trim(); + return value.Length == 0 || value.Any(char.IsControl) ? null : (match.Groups[1].Value, value); + } + + /// "-" (no key), a bare token (Bearer), or "Name: value" lines. Returns a problem, or null with filled. + internal static string? ParseKeyAnswer(string answer, Dictionary headers) + { + var text = answer.Trim(); + if (text == "-") return null; + var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (lines.Length == 1 && ParseHeader(lines[0]) is null) + { + if (lines[0].Any(char.IsWhiteSpace)) return "That doesn't look like a token (it has spaces) or a \"Name: value\" header."; + headers["Authorization"] = $"Bearer {lines[0]}"; + return null; + } + foreach (var line in lines) + { + if (ParseHeader(line) is not { } header) return "Each line must be \"Header-Name: value\"."; + headers[header.Name] = header.Value; + } + return null; + } + + [GeneratedRegex(@"^([^:\s]+):\s+(.+)$", RegexOptions.Singleline)] + private static partial Regex HeaderLine(); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpExtension.cs b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpExtension.cs new file mode 100644 index 0000000..98dc957 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpExtension.cs @@ -0,0 +1,302 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using Volcanic.AgenticCore.Abstractions.Extensions; +using Volcanic.AgenticCore.Abstractions.Extensions.Events; +using Volcanic.AgenticCore.Abstractions.Messages; + +namespace Volcanic.AgenticCore.Extensions.Mcp.Remote; + +/// +/// Remote MCP servers (Streamable HTTP or SSE) per chat, from the workspace's .volcanic/mcp/servers.json. Only the +/// owner's commands (/mcp_add, /mcp_remove, /mcp_tools, /mcp_list) change the servers; the +/// agent can't, because entries are sealed with a host secret. Tools appear as <server>__<tool>. A server +/// that can't be reached leaves the session without its tools, and the agent's system prompt says why. Instantiated +/// per session. +/// +public sealed class RemoteMcpExtension(RemoteMcpOptions options) : IExtension +{ + public const string ExtensionName = "mcp-remote"; + public const string ToolSeparator = "__"; + internal const string SectionKey = "mcp-remote"; + + private readonly RemoteMcpOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly TimeProvider _time = options.Time ?? TimeProvider.System; + private readonly Lock _lock = new(); + private readonly SemaphoreSlim _sync = new(1, 1); + private readonly Dictionary _servers = new(StringComparer.Ordinal); + private readonly Dictionary _tools = new(StringComparer.Ordinal); + private IReadOnlyList _fileProblems = []; + private IExtensionApi? _api; + private ILogger _logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + private Func _hostRedact = static text => text; + private volatile RemoteServerCipher? _cipher; + private volatile string? _cwd; + + public string Name => ExtensionName; + + internal RemoteMcpOptions Options => _options; + + public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(api); + _api = api; + _logger = api.Logger; + _hostRedact = McpExtension.RedactorOf(api); + // Always registered: an unknown /mcp_add with a key in it would otherwise reach the model as a prompt. + RemoteMcpCommands.Register(api, this); + if (!_options.IsConfigured) + { + api.Logger.LogInformation("Remote MCP servers disabled: no encryption secret (set MCP_ENCRYPTION_SECRET)"); + return ValueTask.CompletedTask; + } + api.On(async (_, ctx, _) => + { + Track(ctx); + await SyncAsync(wait: false).ConfigureAwait(false); // connects in the background; the first run waits for it + }); + api.On(async (e, ctx, _) => + { + Track(ctx); + await SyncAsync(wait: true).ConfigureAwait(false); + if (PromptSection() is { } section) e.SystemPromptOptions.Sections[SectionKey] = section; + }); + api.On((e, _, _) => + { + for (var i = 0; i < e.Content.Count; i++) + if (e.Content[i] is TextContent text && Redact(text.Text) is var redacted && redacted != text.Text) + e.Content[i] = new TextContent(redacted); + return ValueTask.CompletedTask; + }); + api.On(async (e, _, _) => + { + if (e.Reason is not (SessionShutdownReason.Quit or SessionShutdownReason.Reload)) return; // /new keeps the connections + List all; + lock (_lock) { all = [.. _servers.Values]; _servers.Clear(); } + await Task.WhenAll(all.Select(s => s.Connection.DisposeAsync().AsTask())).ConfigureAwait(false); + }); + return ValueTask.CompletedTask; + } + + // ---- State for the commands ---- + + internal bool IsConfigured => _options.IsConfigured; + + internal RemoteServerCipher CipherFor(IExtensionContext ctx) + { + Track(ctx); + return _cipher!; + } + + internal sealed record ServerStatus(RemoteServerEntry Entry, string State, IReadOnlyList ExposedTools, IReadOnlyList? AllTools, string? ServerInfo); + + internal IReadOnlyList Statuses() + { + lock (_lock) + return [.. _servers.Values.OrderBy(s => s.Entry.Name, StringComparer.Ordinal).Select(s => new ServerStatus( + s.Entry, + s.Connecting is not null && s.AllTools is null ? "connecting" + : s.Error is { } error ? $"unavailable: {error}" + : $"connected · {s.Exposed.Count} tools", + [.. s.Exposed], + s.AllTools, + s.Connection.ServerInfoName is { } info ? $"{info} {s.Connection.ServerVersion}".Trim() : null))]; + } + + internal IReadOnlyList FileProblems + { + get { lock (_lock) return _fileProblems; } + } + + /// The host's secrets and every header value of this chat's servers become [REDACTED]. + internal string Redact(string text) + { + List secrets; + lock (_lock) secrets = [.. _servers.Values.SelectMany(s => SecretsOf(s.Entry))]; + var result = text; + foreach (var secret in secrets.Distinct(StringComparer.Ordinal).OrderByDescending(s => s.Length)) + result = result.Replace(secret, "[REDACTED]", StringComparison.Ordinal); + return _hostRedact(result); + } + + /// A header value, and its credential alone when it has a scheme ("Bearer abc…" → "abc…"). Short values are left alone. + internal static IEnumerable SecretsOf(RemoteServerEntry entry) + { + foreach (var value in entry.Headers.Values) + { + if (value.Length >= 6) yield return value; + var space = value.IndexOf(' ', StringComparison.Ordinal); + if (space > 0 && value[(space + 1)..].Trim() is { Length: >= 6 } credential) yield return credential; + } + } + + /// + /// Brings the session in line with servers.json: drops removed servers, connects new or changed ones, retries + /// failed ones whose delay has passed. With it returns once every connect in flight has ended + /// (each is bounded by ). + /// + internal async Task SyncAsync(bool wait) + { + if (_cipher is not { } cipher || _cwd is not { } cwd) return; + var pending = new List(); + await _sync.WaitAsync().ConfigureAwait(false); + try + { + var read = RemoteServersFile.Read(cwd, cipher); + var problems = read.Error is { } error ? [error, .. read.Problems] : read.Problems; + List removed = []; + lock (_lock) + { + foreach (var problem in problems.Except(_fileProblems)) + _logger.LogWarning("Remote MCP: {Problem}", problem); + _fileProblems = problems; + var wanted = read.Servers.ToDictionary(s => s.Name, StringComparer.Ordinal); + foreach (var state in _servers.Values.ToList()) + { + if (wanted.TryGetValue(state.Entry.Name, out var entry) && entry.Digest() == state.Digest) continue; + removed.Add(state); + RemoveLocked(state); + } + foreach (var entry in read.Servers) + { + if (!_servers.TryGetValue(entry.Name, out var state)) + { + state = new ServerState(entry, new RemoteServerConnection(entry, _options)); + _servers[entry.Name] = state; + } + if (state.Connecting is null && state.AllTools is null && (state.RetryAt is null || _time.GetUtcNow() >= state.RetryAt)) + state.Connecting = Task.Run(() => ConnectAsync(state)); + if (state.Connecting is { } connecting) pending.Add(connecting); + } + } + foreach (var state in removed) _ = state.Connection.DisposeAsync().AsTask(); + } + finally + { + _sync.Release(); + } + if (wait) await Task.WhenAll(pending).ConfigureAwait(false); + } + + private async Task ConnectAsync(ServerState state) + { + try + { + var tools = await state.Connection.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + lock (_lock) + { + if (!IsCurrent(state)) return; + state.AllTools = tools; + state.Error = null; + state.Failures = 0; + state.RetryAt = null; + ApplyToolsLocked(state); + } + _logger.LogInformation("Remote MCP server {Name}: {Count} tools ({Server} {Version})", state.Entry.Name, state.Exposed.Count, + state.Connection.ServerInfoName, state.Connection.ServerVersion); + } + catch (Exception ex) when (ex is RemoteServerException or ObjectDisposedException) + { + if (ex is ObjectDisposedException) return; // removed while connecting + var error = Redact(ex.Message); + lock (_lock) + { + if (!IsCurrent(state)) return; + state.Failures++; + state.Error = error; + var delay = TimeSpan.FromTicks(Math.Min(_options.MaxRetryDelay.Ticks, _options.RetryDelay.Ticks * (1L << Math.Min(state.Failures - 1, 10)))); + state.RetryAt = _time.GetUtcNow() + delay; + } + _logger.LogWarning("Remote MCP server {Name} is unavailable: {Error}", state.Entry.Name, state.Error); + } + finally + { + lock (_lock) state.Connecting = null; + } + } + + private bool IsCurrent(ServerState state) => _servers.TryGetValue(state.Entry.Name, out var current) && ReferenceEquals(current, state); + + /// Registers (or re-binds) the allowed tools as <server>__<tool> and activates them. + private void ApplyToolsLocked(ServerState state) + { + var api = _api!; + var allowed = state.Entry.Tools is { } list ? list.ToHashSet(StringComparer.Ordinal) : null; + var tools = state.AllTools!.Where(t => allowed is null || allowed.Contains(t.Name)).ToList(); + var exposed = ToolNaming.Expose([.. tools.Select(t => t.Name)], state.Entry.Name + ToolSeparator); + var taken = api.GetAllTools().Where(t => t.Source != ExtensionName).Select(t => t.Name).ToHashSet(StringComparer.Ordinal); + state.Exposed.Clear(); + for (var i = 0; i < tools.Count; i++) + { + var name = exposed[i]; + if (taken.Contains(name)) + { + _logger.LogWarning("Remote MCP server {Server}: tool {Tool} clashes with an existing tool and is skipped", state.Entry.Name, name); + continue; + } + if (_tools.TryGetValue(name, out var tool) && tool.Server is { } owner && owner != state.Entry.Name) + { + _logger.LogWarning("Remote MCP tool {Tool} is already offered by {Owner}; {Server}'s is skipped", name, owner, state.Entry.Name); + continue; + } + if (tool is null) + { + tool = new RemoteMcpTool(name); + tool.Bind(tools[i], state.Connection); + api.RegisterTool(tool); + _tools[name] = tool; + } + else tool.Bind(tools[i], state.Connection); + state.Exposed.Add(name); + } + api.SetActiveTools(api.GetActiveTools().Union(state.Exposed, StringComparer.Ordinal)); + } + + private void RemoveLocked(ServerState state) + { + _servers.Remove(state.Entry.Name); + if (state.Exposed.Count == 0) return; + var gone = state.Exposed.ToHashSet(StringComparer.Ordinal); + foreach (var name in gone) _tools[name].Unbind(); + _api!.SetActiveTools(_api.GetActiveTools().Where(n => !gone.Contains(n))); + } + + /// What the agent is told: connected servers in one line each, and why the others are missing. + internal string? PromptSection() + { + IReadOnlyList statuses = Statuses(); + var problems = FileProblems; + if (statuses.Count == 0 && problems.Count == 0) return null; + var sb = new StringBuilder("## Remote MCP servers\n"); + sb.Append("The owner manages this chat's MCP servers with /mcp_list, /mcp_add, /mcp_remove and /mcp_tools; you can't add or change them (never edit .volcanic/mcp/servers.json). "); + sb.Append("A server's tools are named __.\n"); + foreach (var s in statuses) + { + var url = RemoteServersFile.DisplayUrl(s.Entry.Url); + sb.Append(s.State.StartsWith("unavailable", StringComparison.Ordinal) + ? $"- {s.Entry.Name} ({url}) is UNAVAILABLE, its tools are missing in this conversation: {s.State["unavailable: ".Length..]}. It is retried automatically; tell the user if they need it.\n" + : $"- {s.Entry.Name} ({url}): {s.State}\n"); + } + foreach (var problem in problems) sb.Append($"- Not loaded: {problem}\n"); + return Redact(sb.ToString().TrimEnd()); + } + + private void Track(IExtensionContext ctx) + { + _cwd = ctx.Cwd; + if (_cipher is null && _options.IsConfigured) _cipher = new RemoteServerCipher(_options.Secret!, ctx.SessionManager.Scope); + } + + private sealed class ServerState(RemoteServerEntry entry, RemoteServerConnection connection) + { + public RemoteServerEntry Entry { get; } = entry; + public string Digest { get; } = entry.Digest(); + public RemoteServerConnection Connection { get; } = connection; + public Task? Connecting { get; set; } + public IReadOnlyList? AllTools { get; set; } + public string? Error { get; set; } + public int Failures { get; set; } + public DateTimeOffset? RetryAt { get; set; } + public List Exposed { get; } = []; + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpOptions.cs b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpOptions.cs new file mode 100644 index 0000000..aa7648f --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpOptions.cs @@ -0,0 +1,37 @@ +namespace Volcanic.AgenticCore.Extensions.Mcp.Remote; + +/// +/// Remote MCP servers per chat (). Process-wide; the servers themselves are per chat. +/// Without the commands only explain how to turn the feature on. +/// +public sealed record RemoteMcpOptions +{ + /// + /// Derives the key that seals each chat's server headers. Must be a host secret the agent's processes never see (the + /// Telegram host uses MCP_ENCRYPTION_SECRET, else the bot token). Changing it makes every stored server unreadable. + /// + public string? Secret { get; init; } + + /// Handshake plus tools/list, per server. A server that misses it starts the session without its tools. + public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15); + + /// One tools/call. + public TimeSpan CallTimeout { get; init; } = TimeSpan.FromSeconds(120); + + /// First retry delay for a server that failed to connect; doubles per failure up to . + public TimeSpan RetryDelay { get; init; } = TimeSpan.FromSeconds(60); + + public TimeSpan MaxRetryDelay { get; init; } = TimeSpan.FromMinutes(15); + + /// How long /mcp_add waits for the key when it asks for one. + public TimeSpan KeyPromptTimeout { get; init; } = TimeSpan.FromMinutes(5); + + /// Tests pass a handler over an in-memory server; null uses one shared socket handler. + public Func? HttpHandlerFactory { get; init; } + + public TimeProvider? Time { get; init; } + + public bool IsConfigured => !string.IsNullOrWhiteSpace(Secret); + + public override string ToString() => $"RemoteMcpOptions {{ Configured = {IsConfigured}, ConnectTimeout = {ConnectTimeout} }}"; +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpTool.cs b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpTool.cs new file mode 100644 index 0000000..93a7962 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteMcpTool.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using ModelContextProtocol.Protocol; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Tools; + +namespace Volcanic.AgenticCore.Extensions.Mcp.Remote; + +/// +/// A remote server tool as an agent tool. The registry can't drop or replace a tool, so the same instance is re-bound when +/// its server is added again or reconnects with a new schema, and unbound (and deactivated) when the server is removed. +/// +internal sealed class RemoteMcpTool(string exposedName) : ITool +{ + private volatile Binding? _binding; + + public string Name { get; } = exposedName; + public string Description => _binding?.Description ?? "Unavailable MCP tool."; + public JsonElement Parameters => _binding?.Parameters ?? McpTool.NormalizeSchema(default); + public string? PromptSnippet => null; + public ToolExecutionMode ExecutionMode => ToolExecutionMode.Sequential; + + /// The server this tool currently calls, or null once the server is gone. + public string? Server => _binding?.Connection.Name; + + public void Bind(Tool tool, RemoteServerConnection connection) + { + var description = !string.IsNullOrWhiteSpace(tool.Description) ? tool.Description! + : !string.IsNullOrWhiteSpace(tool.Annotations?.Title ?? tool.Title) ? (tool.Annotations?.Title ?? tool.Title)! + : tool.Name; + _binding = new Binding(tool.Name, $"[{connection.Name} MCP] {description}", McpTool.NormalizeSchema(tool.InputSchema), connection); + } + + public void Unbind() => _binding = null; + + public async Task ExecuteAsync(ToolCallContent call, ToolInvocation invocation, CancellationToken cancellationToken) + { + if (_binding is not { } binding) + throw new InvalidOperationException($"{Name}: its MCP server was removed from this chat."); + var result = await binding.Connection.CallAsync(binding.McpName, call.Arguments, cancellationToken).ConfigureAwait(false); + return McpResultMapper.Map(binding.Connection.Name, binding.McpName, result); + } + + private sealed record Binding(string McpName, string Description, JsonElement Parameters, RemoteServerConnection Connection); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteServerConnection.cs b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteServerConnection.cs new file mode 100644 index 0000000..4054af2 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteServerConnection.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Volcanic.AgenticCore.Extensions.Mcp.Remote; + +/// +/// One remote server for one chat session: an SDK client over HTTP (Streamable HTTP, or SSE). Connected by +/// ; a call that fails in the transport drops the client and the next call connects again. +/// +internal sealed class RemoteServerConnection : IAsyncDisposable +{ + private static readonly HttpClient SharedHttp = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + { + Timeout = Timeout.InfiniteTimeSpan, // Streamable HTTP keeps a GET stream open; every request has its own deadline + }; + + private readonly RemoteMcpOptions _options; + private readonly SemaphoreSlim _gate = new(1, 1); + private McpClient? _client; + private HttpClient? _ownHttp; + private int _disposed; + private long _nextRequestId; + + public RemoteServerConnection(RemoteServerEntry entry, RemoteMcpOptions options) + { + Entry = entry; + _options = options; + } + + public RemoteServerEntry Entry { get; } + public string Name => Entry.Name; + public string? ServerInfoName { get; private set; } + public string? ServerVersion { get; private set; } + + /// Connects (if needed) and lists the tools. Throws with a readable message. + public async Task> ConnectAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(_options.ConnectTimeout); + try + { + var client = await EnsureClientAsync(cts.Token).ConfigureAwait(false); + var tools = await client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + return [.. tools.Select(t => t.ProtocolTool)]; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + await DropClientAsync().ConfigureAwait(false); + throw new RemoteServerException($"{Name}: no answer from {RemoteServersFile.DisplayUrl(Entry.Url)} within {_options.ConnectTimeout.TotalSeconds:0}s"); + } + catch (Exception ex) when (ex is not OperationCanceledException and not ObjectDisposedException) + { + await DropClientAsync().ConfigureAwait(false); + throw new RemoteServerException($"{Name}: cannot connect to {RemoteServersFile.DisplayUrl(Entry.Url)}: {Describe(ex)}", ex); + } + } + finally + { + _gate.Release(); + } + } + + /// + /// Calls a tool, connecting first if the last call dropped the client. Throws , + /// , , or the SDK's protocol exception. + /// + public async Task CallAsync(string mcpName, JsonElement arguments, CancellationToken cancellationToken) + { + McpClient client; + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var connect = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connect.CancelAfter(_options.ConnectTimeout); + try + { + client = await EnsureClientAsync(connect.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new RemoteServerException($"{Name} MCP server did not answer within {_options.ConnectTimeout.TotalSeconds:0}s; try again later."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new RemoteServerException($"{Name} MCP server is unreachable: {Describe(ex)}", ex); + } + } + finally + { + _gate.Release(); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_options.CallTimeout); + var id = new RequestId($"volcanic-{Interlocked.Increment(ref _nextRequestId).ToString(CultureInfo.InvariantCulture)}"); + var request = new CallToolRequestParams { Name = mcpName, Arguments = ToArguments(arguments) }; + try + { + return await client.SendRequestAsync( + RequestMethods.ToolsCall, request, McpJsonUtilities.DefaultOptions, id, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await SendCancelledAsync(client, id, "aborted").ConfigureAwait(false); + throw; + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + await SendCancelledAsync(client, id, "timed out").ConfigureAwait(false); + throw new TimeoutException($"{mcpName} timed out after {_options.CallTimeout.TotalSeconds:0}s"); + } + catch (Exception ex) when (ex is not McpProtocolException) + { + await DropClientAsync(client).ConfigureAwait(false); + throw new RemoteServerException($"{Name} MCP server failed: {Describe(ex)}. The next call reconnects; the server may or may not have run this one.", ex); + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + await _gate.WaitAsync().ConfigureAwait(false); + try { await DropClientAsync().ConfigureAwait(false); } + finally { _gate.Release(); } + } + + /// Caller holds the gate. + private async Task EnsureClientAsync(CancellationToken cancellationToken) + { + if (_client is { } existing) return existing; + var http = SharedHttp; + if (_options.HttpHandlerFactory is { } factory) http = _ownHttp ??= new HttpClient(factory()) { Timeout = Timeout.InfiniteTimeSpan }; + var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Name = Name, + Endpoint = new Uri(Entry.Url), + TransportMode = Entry.Transport switch + { + RemoteTransport.Http => HttpTransportMode.StreamableHttp, + RemoteTransport.Sse => HttpTransportMode.Sse, + _ => HttpTransportMode.AutoDetect, + }, + AdditionalHeaders = new Dictionary(Entry.Headers, StringComparer.OrdinalIgnoreCase), + ConnectionTimeout = _options.ConnectTimeout, + }, http, loggerFactory: null, ownsHttpClient: false); + var client = await McpClient.CreateAsync( + transport, + new McpClientOptions { ClientInfo = new Implementation { Name = "volcanic-agentic-core", Version = "1.0.0" }, InitializationTimeout = _options.ConnectTimeout }, + loggerFactory: null, + cancellationToken).ConfigureAwait(false); + ServerInfoName = client.ServerInfo?.Name; + ServerVersion = client.ServerInfo?.Version; + _client = client; + return client; + } + + /// Drops the current client (or only , if it is still the current one). + private async Task DropClientAsync(McpClient? expected = null) + { + var client = expected is null ? Interlocked.Exchange(ref _client, null) : Interlocked.CompareExchange(ref _client, null, expected) == expected ? expected : null; + if (client is null) return; + try { await client.DisposeAsync().ConfigureAwait(false); } + catch (Exception) { /* a broken transport may throw on dispose */ } + } + + private static async Task SendCancelledAsync(McpClient client, RequestId id, string reason) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await client.SendNotificationAsync(NotificationMethods.CancelledNotification, + new CancelledNotificationParams { RequestId = id, Reason = reason }, McpJsonUtilities.DefaultOptions, cts.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) { /* best effort */ } + } + + /// The innermost useful message: "Connection refused", "401 (Unauthorized)" and the like. + internal static string Describe(Exception ex) + { + var message = ex.Message; + for (var inner = ex.InnerException; inner is not null; inner = inner.InnerException) + if (inner is HttpRequestException or System.Net.Sockets.SocketException) message = $"{message} ({inner.Message})"; + return message; + } + + private static Dictionary ToArguments(JsonElement arguments) + { + var result = new Dictionary(StringComparer.Ordinal); + if (arguments.ValueKind == JsonValueKind.Object) + foreach (var property in arguments.EnumerateObject()) result[property.Name] = property.Value.Clone(); + return result; + } +} + +/// A remote server could not be reached or broke off; the message is meant for the agent and the owner. +internal sealed class RemoteServerException(string message, Exception? inner = null) : Exception(message, inner); diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteServersFile.cs b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteServersFile.cs new file mode 100644 index 0000000..5c3fac5 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Remote/RemoteServersFile.cs @@ -0,0 +1,306 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Volcanic.AgenticCore.Extensions.Mcp.Remote; + +/// How the client talks to a remote server. tries Streamable HTTP, then falls back to SSE. +public enum RemoteTransport { Auto, Http, Sse } + +/// +/// One remote MCP server of a chat. are sent with every request and live sealed on disk; +/// shows header names only. null means every tool the server offers. +/// is headers today; another kind (OAuth, say) would keep its tokens in the same sealed part. +/// +public sealed record RemoteServerEntry( + string Name, + string Url, + RemoteTransport Transport, + IReadOnlyList? Tools, + IReadOnlyDictionary Headers, + string Auth = RemoteServerEntry.HeadersAuth) +{ + public const string HeadersAuth = "headers"; + + public override string ToString() => + $"RemoteServerEntry {{ Name = {Name}, Url = {RemoteServersFile.DisplayUrl(Url)}, Transport = {Transport}, Headers = [{string.Join(", ", Headers.Keys)}] }}"; + + /// Changes when anything that needs a new connection changes (header values included, as a digest). + internal string Digest() + { + var canonical = JsonSerializer.Serialize(new + { + Name, + Url, + Transport = Transport.ToString(), + Tools, + Auth, + Headers = Headers.OrderBy(h => h.Key, StringComparer.OrdinalIgnoreCase).Select(h => new[] { h.Key, h.Value }).ToArray(), + }); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + } +} + +/// Entries that were skipped, described without secrets. +/// The file as a whole could not be read; nothing was loaded. +public sealed record RemoteServersRead(IReadOnlyList Servers, IReadOnlyList Problems, string? Error); + +/// +/// <workspace>/.volcanic/mcp/servers.json: the chat's remote MCP servers. +/// +/// { "version": 1, "servers": [ { "name": "notes", "url": "https://…/mcp", "transport": "auto", "tools": null, +/// "auth": "headers", "sealed": "<base64>" } ] } +/// +/// sealed holds {"headers":{…}} encrypted with . The visible fields and the +/// chat are its associated data, so an entry written or changed by anyone without the host's secret (the agent's own +/// bash, for one) doesn't open and is skipped: only the owner's commands can add a server. Writes are atomic and +/// owner-only on Unix. +/// +public static partial class RemoteServersFile +{ + public const int Version = 1; + public const int MaxNameLength = 20; + + private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite; + private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + + [GeneratedRegex("^[a-z0-9][a-z0-9-]{0,19}\\z", RegexOptions.CultureInvariant)] + private static partial Regex NamePattern(); + + [GeneratedRegex("^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,100}\\z", RegexOptions.CultureInvariant)] + private static partial Regex HeaderNamePattern(); + + public static string RelativePath => ".volcanic/mcp/servers.json"; + + public static string PathFor(string workspace) => Path.Combine(workspace, ".volcanic", "mcp", "servers.json"); + + public static bool IsValidName(string name) => NamePattern().IsMatch(name); + + public static bool IsValidHeaderName(string name) => HeaderNamePattern().IsMatch(name); + + /// Null when valid, else why not (an absolute http(s) URL without user info). + public static string? CheckUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + return "the url must be an absolute http(s) URL"; + if (uri.UserInfo.Length > 0) return "put credentials in a header, not in the url"; + return null; + } + + /// Scheme, host, port and path only: a query or fragment may carry credentials. + public static string DisplayUrl(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) + ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped) + : "(invalid url)"; + + public static RemoteServersRead Read(string workspace, RemoteServerCipher cipher) + { + ArgumentNullException.ThrowIfNull(cipher); + var path = PathFor(workspace); + if (!File.Exists(path)) return new([], [], null); + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return new([], [], $"{RelativePath} could not be read: {ex.GetType().Name}."); + } + if (root is not JsonObject file || file["servers"] is not JsonArray servers) + return new([], [], $"{RelativePath} must be an object with a \"servers\" array."); + + var entries = new List(); + var problems = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var index = 0; + foreach (var item in servers) + { + index++; + if (item is not JsonObject server) { problems.Add($"entry {index} is not an object"); continue; } + var name = Str(server, "name"); + if (name is null || !IsValidName(name)) { problems.Add($"entry {index}: invalid name"); continue; } + var url = Str(server, "url"); + if (url is null || CheckUrl(url) is not null) { problems.Add($"{name}: invalid url"); continue; } + if (!TryTransport(Str(server, "transport"), out var transport)) { problems.Add($"{name}: transport must be auto, http or sse"); continue; } + if (!TryTools(server["tools"], out var tools)) { problems.Add($"{name}: tools must be null or a list of names"); continue; } + var auth = Str(server, "auth") ?? RemoteServerEntry.HeadersAuth; + if (auth != RemoteServerEntry.HeadersAuth) { problems.Add($"{name}: auth '{auth}' is not supported"); continue; } + var headers = Str(server, "sealed") is { } data + ? OpenHeaders(cipher.Open(data, AssociatedData(cipher.Scope, name, url, transport, tools, auth))) + : null; + if (headers is null) + { + problems.Add($"{name}: not added by /mcp_add in this chat (or the encryption secret changed); add it again with /mcp_add"); + continue; + } + if (!seen.Add(name)) { problems.Add($"{name}: duplicate name"); continue; } + entries.Add(new RemoteServerEntry(name, url, transport, tools, headers, auth)); + } + return new(entries, problems, null); + } + + /// Replaces the file with , every entry sealed afresh. + public static void Write(string workspace, RemoteServerCipher cipher, IEnumerable servers) + { + ArgumentNullException.ThrowIfNull(cipher); + var array = new JsonArray(); + foreach (var s in servers) + { + var plain = JsonSerializer.SerializeToUtf8Bytes(new JsonObject { ["headers"] = HeadersNode(s.Headers) }); + var sealedData = cipher.Seal(plain, AssociatedData(cipher.Scope, s.Name, s.Url, s.Transport, s.Tools, s.Auth)); + CryptographicOperations.ZeroMemory(plain); + array.Add(new JsonObject + { + ["name"] = s.Name, + ["url"] = s.Url, + ["transport"] = TransportName(s.Transport), + ["tools"] = s.Tools is null ? null : new JsonArray([.. s.Tools.Select(t => (JsonNode)t)]), + ["auth"] = s.Auth, + ["sealed"] = sealedData, + }); + } + var path = PathFor(workspace); + var directory = Path.GetDirectoryName(path)!; + CreatePrivateDirectory(Path.GetDirectoryName(directory)!); + CreatePrivateDirectory(directory); + var temp = path + ".tmp"; + var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile; + using (var stream = new FileStream(temp, options)) + JsonSerializer.Serialize(stream, new JsonObject { ["version"] = Version, ["servers"] = array }, Indented); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PrivateFile); + File.Move(temp, path, overwrite: true); + } + + public static string TransportName(RemoteTransport transport) => transport switch + { + RemoteTransport.Http => "http", + RemoteTransport.Sse => "sse", + _ => "auto", + }; + + public static bool TryTransport(string? value, out RemoteTransport transport) + { + transport = RemoteTransport.Auto; + switch (value?.ToLowerInvariant()) + { + case null or "auto": return true; + case "http" or "streamable-http" or "streamablehttp": transport = RemoteTransport.Http; return true; + case "sse": transport = RemoteTransport.Sse; return true; + default: return false; + } + } + + /// The visible fields and the chat, bound to the sealed headers: change any of them and the entry no longer opens. + private static byte[] AssociatedData(string scope, string name, string url, RemoteTransport transport, IReadOnlyList? tools, string auth) => + JsonSerializer.SerializeToUtf8Bytes(new object?[] { "volcanic mcp server v1", scope, name, url, TransportName(transport), tools, auth }); + + private static JsonObject HeadersNode(IReadOnlyDictionary headers) + { + var node = new JsonObject(); + foreach (var (key, value) in headers) node[key] = value; + return node; + } + + private static Dictionary? OpenHeaders(byte[]? plain) + { + if (plain is null) return null; + try + { + if (JsonNode.Parse(plain) is not JsonObject { } o || o["headers"] is not JsonObject headers) return null; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in headers) + if (value is JsonValue v && v.TryGetValue(out var s)) result[key] = s; + return result; + } + catch (JsonException) + { + return null; + } + finally + { + CryptographicOperations.ZeroMemory(plain); + } + } + + private static bool TryTools(JsonNode? node, out IReadOnlyList? tools) + { + tools = null; + if (node is null) return true; + if (node is not JsonArray array) return false; + var list = new List(); + foreach (var item in array) + { + if (item is not JsonValue v || !v.TryGetValue(out var s) || string.IsNullOrWhiteSpace(s)) return false; + list.Add(s); + } + tools = list; + return true; + } + + private static string? Str(JsonObject o, string property) => + o[property] is JsonValue v && v.TryGetValue(out var s) && s.Length > 0 ? s : null; + + private static void CreatePrivateDirectory(string directory) + { + if (Directory.Exists(directory)) return; + if (OperatingSystem.IsWindows()) Directory.CreateDirectory(directory); + else Directory.CreateDirectory(directory, PrivateDirectory); + } +} + +/// +/// AES-256-GCM for the sealed part of servers.json. The key is derived (HKDF-SHA256) from a host secret the agent's +/// processes never see; the caller's associated data binds an entry to its chat and visible fields. A new secret makes +/// every stored server unreadable: add them again. Sealed form: base64 of nonce (12 bytes), tag (16 bytes), ciphertext. +/// +public sealed class RemoteServerCipher +{ + private const int NonceSize = 12; + private const int TagSize = 16; + private static readonly byte[] KeyInfo = Encoding.UTF8.GetBytes("volcanic mcp servers.json v1"); + private readonly byte[] _key; + + public RemoteServerCipher(string secret, string scope) + { + ArgumentException.ThrowIfNullOrEmpty(secret); + ArgumentException.ThrowIfNullOrEmpty(scope); + _key = HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret), 32, info: KeyInfo); + Scope = scope; + } + + /// The session scope (one per chat); part of every entry's associated data. + public string Scope { get; } + + public string Seal(byte[] plain, byte[] associatedData) + { + var output = new byte[NonceSize + TagSize + plain.Length]; + var nonce = output.AsSpan(0, NonceSize); + RandomNumberGenerator.Fill(nonce); + using var aes = new AesGcm(_key, TagSize); + aes.Encrypt(nonce, plain, output.AsSpan(NonceSize + TagSize), output.AsSpan(NonceSize, TagSize), associatedData); + return Convert.ToBase64String(output); + } + + /// Null when the data is damaged, sealed with another secret, or its associated data differs. + public byte[]? Open(string data, byte[] associatedData) + { + try + { + var input = Convert.FromBase64String(data); + if (input.Length < NonceSize + TagSize) return null; + var plain = new byte[input.Length - NonceSize - TagSize]; + using var aes = new AesGcm(_key, TagSize); + aes.Decrypt(input.AsSpan(0, NonceSize), input.AsSpan(NonceSize + TagSize), input.AsSpan(NonceSize, TagSize), plain, associatedData); + return plain; + } + catch (Exception ex) when (ex is FormatException or CryptographicException) + { + return null; + } + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj index 3d7585f..1686bfe 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj @@ -1,6 +1,6 @@ - MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset. + MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset, and remote HTTP/SSE servers per chat. diff --git a/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs b/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs index eece049..6b785e5 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs @@ -57,7 +57,7 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti try { var me = await bot.GetMe(stoppingToken).ConfigureAwait(false); - var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar); + var menu = RemoteMcpSetup.MenuFor(GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar)); await bot.SetMyCommands(menu, cancellationToken: stoppingToken).ConfigureAwait(false); logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id); if (options.Triggers.IsEnabled && !me.CanReadAllGroupMessages) diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs index ee2b698..b58ddcd 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs @@ -157,7 +157,8 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon /compact – summarise the conversation to free context /thinking [level] – show or set the thinking level /timezone [IANA name|reset] – this chat's time zone - """ + SchedulerSetup.HelpLines + "\n/a2a – A2A callers, remote agents and recent tasks of this chat" + TriggersSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands); + """ + SchedulerSetup.HelpLines + "\n/a2a – A2A callers, remote agents and recent tasks of this chat" + TriggersSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands) + + (chat.Session.Commands.Any(c => c.Name == "mcp_add") ? RemoteMcpSetup.HelpLines : ""); private string Status(ChatContext chat) { diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs index 79707f9..bd9839a 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs @@ -6,6 +6,7 @@ using Volcanic.AgenticCore.Abstractions.Messages; using Volcanic.AgenticCore.Abstractions.Ui; using Volcanic.AgenticCore.Extensions.A2A; using Volcanic.AgenticCore.Extensions.GoogleCalendar; +using Volcanic.AgenticCore.Extensions.Mcp.Remote; using Volcanic.AgenticCore.Extensions.Scheduler; using Volcanic.AgenticCore.Features.Agent; using Volcanic.AgenticCore.Features.Extensions; @@ -85,7 +86,8 @@ public sealed class ChatRegistry( TriggerServices? triggers = null, A2AClientSettings? a2aClient = null, A2AWatchRegistry? a2aWatches = null, - ITextToSpeech? textToSpeech = null) : IAsyncDisposable + ITextToSpeech? textToSpeech = null, + RemoteMcpOptions? remoteMcp = null) : IAsyncDisposable { private readonly ConcurrentDictionary>> _chats = new(); private readonly ILogger _logger = loggerFactory.CreateLogger(); @@ -139,6 +141,8 @@ public sealed class ChatRegistry( // Chat sessions only: A2A server sessions are built by the server's own factory call and never get the client. if (a2aClient is { Enabled: true }) extras.Add(ExtensionRegistration.FromInstance(new A2AClientExtension(a2aClient, Scheduling.TelegramJobDelivery.TargetOf(chatId), a2aWatches))); + // Chat sessions only, like the A2A client: servers belong to the chat's owner. + if (remoteMcp is not null) extras.Add(RemoteMcpSetup.Registration(remoteMcp)); AgentSession session; try { diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs index afadcfb..d9fd218 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs @@ -16,6 +16,7 @@ public sealed class TelegramUi(TelegramSender sender, ChatRenderer renderer) : I private readonly ConcurrentDictionary> _choices = new(); private TaskCompletionSource? _input; + private volatile bool _inputSecret; /// True while an question waits for its answer. public bool IsWaitingForInput => Volatile.Read(ref _input) is { Task.IsCompleted: false }; @@ -54,6 +55,7 @@ public sealed class TelegramUi(TelegramSender sender, ChatRenderer renderer) : I { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Interlocked.Exchange(ref _input, tcs)?.TrySetResult(null); // one question at a time: a newer one cancels the older + _inputSecret = requestOptions?.Secret == true; try { var markup = new ForceReplyMarkup { InputFieldPlaceholder = placeholder }; @@ -81,7 +83,17 @@ public sealed class TelegramUi(TelegramSender sender, ChatRenderer renderer) : I } /// Answer a pending question. False when none is pending. - public bool TryResolveInput(string text) => Interlocked.Exchange(ref _input, null) is { } tcs && tcs.TrySetResult(text); + public bool TryResolveInput(string text) => TryResolveInput(text, out _); + + /// + /// Answer a pending question; tells the caller the question asked for + /// a secret (), so the answer's message should be deleted. + /// + public bool TryResolveInput(string text, out bool secret) + { + secret = _inputSecret; + return Interlocked.Exchange(ref _input, null) is { } tcs && tcs.TrySetResult(text); + } private static async Task WaitAsync(TaskCompletionSource tcs, UiRequestOptions? requestOptions, CancellationToken cancellationToken) { diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs index e2d6878..1700bd5 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs @@ -104,14 +104,19 @@ public sealed class UpdateRouter( // weren't forwarded: a forwarded message is content, never a command or an answer. if (!InboundComposer.HasAttachment(message) && message.ForwardOrigin is null) { - if (ChatCommands.Parse(text) is not null) + if (ChatCommands.Parse(text) is { } parsed) { if (await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return; // Not one of ours: straight to the session, without headers or the window, so extension commands still parse. _ = chat.PromptInBackground(text); + if (RemoteMcpSetup.CarriesKey(parsed.Name, parsed.Args)) _ = DeleteQuietlyAsync(message); + return; + } + if (chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text, out var secret)) + { + if (secret) _ = DeleteQuietlyAsync(message); return; } - if (chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return; } // Take the lease now, in update order; compose (downloads) in the background so polling isn't blocked. @@ -149,4 +154,7 @@ public sealed class UpdateRouter( await sender.SendTextAsync(message.Chat.Id, reply, cancellationToken: cancellationToken).ConfigureAwait(false); return true; } + + /// Removes a message that carried a key (best effort; the sender logs a failure). + private Task DeleteQuietlyAsync(Message message) => sender.DeleteAsync(message.Chat.Id, message.Id, CancellationToken.None); } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index c04271c..70a78fb 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -15,6 +15,7 @@ using Volcanic.AgenticCore.A2A.Server; using Volcanic.AgenticCore.Extensions.A2A; using Volcanic.AgenticCore.Extensions.ClaudeCode; using Volcanic.AgenticCore.Extensions.Mcp; +using Volcanic.AgenticCore.Extensions.Mcp.Remote; using Volcanic.AgenticCore.Extensions.Scheduler; using Volcanic.AgenticCore.Features.Processes; using Volcanic.AgenticCore.Hosting; @@ -38,6 +39,8 @@ var calendar = GoogleCalendarSetup.Options( Environment.GetEnvironmentVariable); var processEnvironment = new ProcessEnvironmentOptions(); TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar); +// Each chat's remote MCP servers; their keys are sealed with MCP_ENCRYPTION_SECRET (else the bot token). +var remoteMcp = RemoteMcpSetup.Options(options, Environment.GetEnvironmentVariable, processEnvironment); var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get() ?? new McpOptions(); mcp.ApplyEnvironment(Environment.GetEnvironmentVariable); // Off: no HTTP listener at all (NoHttpServer replaces Kestrel). On: the A2A endpoints on Kestrel (ASPNETCORE_URLS / Urls). @@ -57,6 +60,7 @@ if (errors.Count > 0) builder.Services.AddSingleton(options); builder.Services.AddSingleton(mcp); +builder.Services.AddSingleton(remoteMcp); builder.Services.AddSingleton(scheduling); builder.Services.AddSingleton(options.Speech); if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice @@ -129,6 +133,7 @@ var logger = host.Services.GetRequiredService().CreateLogger("Vo HostProcessHardening.Apply(processEnvironment, logger); logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment)); logger.LogInformation("{Mcp}", mcp.DescribePlaywright()); +logger.LogInformation("{RemoteMcp}", RemoteMcpSetup.Describe(Environment.GetEnvironmentVariable)); logger.LogInformation("{Speech}", options.Speech.Describe()); logger.LogInformation("{Triggers}", options.Triggers.Describe()); logger.LogInformation("{A2A}", TelegramA2ASetup.Describe(a2a)); diff --git a/hosts/Volcanic.AgenticCore.Telegram/RemoteMcpSetup.cs b/hosts/Volcanic.AgenticCore.Telegram/RemoteMcpSetup.cs new file mode 100644 index 0000000..a3322f6 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/RemoteMcpSetup.cs @@ -0,0 +1,52 @@ +using Telegram.Bot.Types; +using Volcanic.AgenticCore.Extensions.Mcp.Remote; +using Volcanic.AgenticCore.Features.Extensions; +using Volcanic.AgenticCore.Features.Processes; + +namespace Volcanic.AgenticCore.Telegram; + +/// +/// Remote MCP servers per chat (). Keys are sealed with MCP_ENCRYPTION_SECRET, or +/// the bot token when that is unset; agent processes see neither (both match the default deny-list). +/// +public static class RemoteMcpSetup +{ + public const string SecretVariable = "MCP_ENCRYPTION_SECRET"; + + public static readonly IReadOnlyList Menu = + [ + new() { Command = "mcp_list", Description = "Remote MCP servers of this chat" }, + new() { Command = "mcp_add", Description = "Add an MCP server: /mcp_add " }, + new() { Command = "mcp_remove", Description = "Remove an MCP server: /mcp_remove " }, + new() { Command = "mcp_tools", Description = "Show or limit a server's tools" }, + ]; + + public const string HelpLines = + "\n/mcp_list, /mcp_add , /mcp_remove , /mcp_tools [a,b|all] – this chat's remote MCP servers"; + + public static IReadOnlyList MenuFor(IReadOnlyList menu) => [.. menu, .. Menu]; + + /// The dedicated secret, else the bot token. Adds the dedicated one to the redacted secret values. + public static RemoteMcpOptions Options(TelegramOptions telegram, Func getEnv, ProcessEnvironmentOptions processEnvironment) + { + ArgumentNullException.ThrowIfNull(telegram); + ArgumentNullException.ThrowIfNull(getEnv); + ArgumentNullException.ThrowIfNull(processEnvironment); + var dedicated = getEnv(SecretVariable) is { } value && !string.IsNullOrWhiteSpace(value) ? value.Trim() : null; + if (dedicated is not null) processEnvironment.SecretValues.Add(dedicated); + return new RemoteMcpOptions { Secret = dedicated ?? (string.IsNullOrWhiteSpace(telegram.BotToken) ? null : telegram.BotToken) }; + } + + public static string Describe(Func getEnv) => + string.IsNullOrWhiteSpace(getEnv(SecretVariable)) + ? $"Remote MCP: keys sealed with the bot token (set {SecretVariable} to keep them across a token change)" + : $"Remote MCP: keys sealed with {SecretVariable}"; + + /// A command whose message holds a key: the router deletes it from the chat after handing it over. + public static bool CarriesKey(string command, string args) => + command == "mcp_add" && (args.Contains("--bearer", StringComparison.Ordinal) || args.Contains("--header", StringComparison.Ordinal)); + + /// A new extension instance per chat session. + public static ExtensionRegistration Registration(RemoteMcpOptions options) => + new(RemoteMcpExtension.ExtensionName, _ => new RemoteMcpExtension(options), "telegram"); +} diff --git a/src/Volcanic.AgenticCore.Abstractions/Ui/IUserInterface.cs b/src/Volcanic.AgenticCore.Abstractions/Ui/IUserInterface.cs index 9a04862..433871f 100644 --- a/src/Volcanic.AgenticCore.Abstractions/Ui/IUserInterface.cs +++ b/src/Volcanic.AgenticCore.Abstractions/Ui/IUserInterface.cs @@ -2,7 +2,11 @@ namespace Volcanic.AgenticCore.Abstractions.Ui; public enum NotifyKind { Info, Warning, Error } -public sealed record UiRequestOptions(TimeSpan? Timeout = null); +/// +/// The answer is a secret (an API key, say): a host that keeps a visible copy of the answer (a chat message) removes it +/// once read. The answer goes to the caller only, never into the conversation. +/// +public sealed record UiRequestOptions(TimeSpan? Timeout = null, bool Secret = false); /// /// The host's user interaction surface, used by extensions (through IExtensionContext.Ui). diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteMcpExtensionTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteMcpExtensionTests.cs new file mode 100644 index 0000000..9d7b889 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteMcpExtensionTests.cs @@ -0,0 +1,153 @@ +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Extensions.Mcp.Remote; +using Volcanic.AgenticCore.Testing; + +namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Remote; + +public class RemoteMcpExtensionTests +{ + private static string LastNote(RemoteMcpTestHost host) => host.Ui.Notes[^1]; + + [Fact] + public async Task Add_asks_for_the_key_then_the_tools_work_in_the_same_session() + { + await using var host = await RemoteMcpTestHost.StartAsync(); + var session = await host.CreateSessionAsync(); + host.Ui.Answer = RemoteTestServer.Token; + + await session.PromptAsync($"/mcp_add notes {host.ServerUrl} --tools echo,whoami"); + + Assert.Contains("Added notes", LastNote(host), StringComparison.Ordinal); + Assert.True(Assert.Single(host.Ui.Questions)!.Secret); + Assert.Contains("notes__echo", session.ActiveTools); + Assert.Contains("notes__whoami", session.ActiveTools); + Assert.DoesNotContain("notes__add", session.ActiveTools); // not on the allowlist + Assert.Empty(session.Messages); // the command and the key never enter the conversation + + host.Faux.Enqueue(FauxTurn.Tool("notes__echo", new { text = "hi" }), FauxTurn.Tool("notes__whoami", new { }), FauxTurn.Say("done")); + await session.PromptAsync("use it"); + + var results = session.Messages.OfType().ToList(); + Assert.Equal("echo: hi", results[0].Text); + Assert.Equal("you sent [REDACTED]", results[1].Text); // a server echoing the key doesn't leak it + Assert.Contains("notes (http://localhost/mcp): connected · 2 tools", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal); + Assert.DoesNotContain(RemoteTestServer.Token, File.ReadAllText(RemoteServersFile.PathFor(host.Workspace)), StringComparison.Ordinal); + } + + [Fact] + public async Task Inline_bearer_works_without_a_question() + { + await using var host = await RemoteMcpTestHost.StartAsync(); + var session = await host.CreateSessionAsync(); + + await session.PromptAsync($"/mcp_add notes {host.ServerUrl} --bearer {RemoteTestServer.Token}"); + + Assert.Empty(host.Ui.Questions); + Assert.Contains("notes__add", session.ActiveTools); + Assert.DoesNotContain(RemoteTestServer.Token, LastNote(host), StringComparison.Ordinal); + } + + [Fact] + public async Task A_wrong_key_is_refused_and_nothing_is_saved() + { + await using var host = await RemoteMcpTestHost.StartAsync(); + var session = await host.CreateSessionAsync(); + + await session.PromptAsync($"/mcp_add notes {host.ServerUrl} --bearer wrong-token-456"); + + Assert.StartsWith("Not added: notes: cannot connect", LastNote(host), StringComparison.Ordinal); + Assert.DoesNotContain("wrong-token-456", LastNote(host), StringComparison.Ordinal); + Assert.False(File.Exists(RemoteServersFile.PathFor(host.Workspace))); + } + + [Fact] + public async Task Servers_load_in_a_new_session_and_remove_drops_the_tools() + { + await using var host = await RemoteMcpTestHost.StartAsync(); + var first = await host.CreateSessionAsync(); + await first.PromptAsync($"/mcp_add notes {host.ServerUrl} --bearer {RemoteTestServer.Token}"); + + var second = await host.CreateSessionAsync(); + host.Faux.Enqueue(FauxTurn.Tool("notes__add", new { a = 2, b = 3 }), FauxTurn.Say("5")); + await second.PromptAsync("add"); + Assert.Equal("5", second.Messages.OfType().Single().Text); + + await second.PromptAsync("/mcp_remove notes"); + Assert.DoesNotContain(second.ActiveTools, t => t.StartsWith("notes__", StringComparison.Ordinal)); + Assert.Contains("No remote MCP servers", await ListAsync(host, second), StringComparison.Ordinal); + } + + [Fact] + public async Task An_unreachable_server_starts_the_session_without_it_and_the_agent_is_told() + { + await using var host = await RemoteMcpTestHost.StartAsync(); + var setup = await host.CreateSessionAsync(); + await setup.PromptAsync($"/mcp_add notes {host.ServerUrl} --bearer {RemoteTestServer.Token}"); + host.Server.Down = true; + + var session = await host.CreateSessionAsync(); + host.Faux.Enqueue(FauxTurn.Say("ok")); + await session.PromptAsync("hello"); + + Assert.DoesNotContain(session.ActiveTools, t => t.StartsWith("notes__", StringComparison.Ordinal)); + var prompt = host.Faux.Requests[^1].SystemPrompt!; + Assert.Contains("notes (http://localhost/mcp) is UNAVAILABLE", prompt, StringComparison.Ordinal); + Assert.Contains("503", prompt, StringComparison.Ordinal); + + host.Server.Down = false; // retried before the next run (RetryDelay is zero here) + host.Faux.Enqueue(FauxTurn.Say("ok")); + await session.PromptAsync("again"); + Assert.Contains("notes__echo", session.ActiveTools); + } + + [Fact] + public async Task An_entry_written_by_the_agent_is_not_loaded() + { + await using var host = await RemoteMcpTestHost.StartAsync(); + var path = RemoteServersFile.PathFor(host.Workspace); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, """{ "version": 1, "servers": [ { "name": "evil", "url": "http://localhost/mcp", "sealed": "AAAA" } ] }"""); + + var session = await host.CreateSessionAsync(); + host.Faux.Enqueue(FauxTurn.Say("ok")); + await session.PromptAsync("hello"); + + Assert.DoesNotContain(session.ActiveTools, t => t.StartsWith("evil__", StringComparison.Ordinal)); + Assert.Contains("evil: not added by /mcp_add", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal); + } + + [Fact] + public async Task Tools_command_changes_the_allowlist() + { + await using var host = await RemoteMcpTestHost.StartAsync(); + var session = await host.CreateSessionAsync(); + await session.PromptAsync($"/mcp_add notes {host.ServerUrl} --bearer {RemoteTestServer.Token}"); + Assert.Contains("notes__add", session.ActiveTools); + + await session.PromptAsync("/mcp_tools notes echo"); + Assert.Contains("notes__echo", session.ActiveTools); + Assert.DoesNotContain("notes__add", session.ActiveTools); + + await session.PromptAsync("/mcp_tools notes"); + Assert.Contains("Offered: ", LastNote(host), StringComparison.Ordinal); + Assert.Contains("Allowed: echo", LastNote(host), StringComparison.Ordinal); + } + + [Fact] + public async Task Without_a_secret_the_commands_explain_and_swallow_the_key() + { + await using var host = await RemoteMcpTestHost.StartAsync(secret: null); + var session = await host.CreateSessionAsync(); + + await session.PromptAsync($"/mcp_add notes {host.ServerUrl} --bearer {RemoteTestServer.Token}"); + + Assert.Equal(RemoteMcpCommands.DisabledReply, LastNote(host)); + Assert.Empty(session.Messages); + } + + private static async Task ListAsync(RemoteMcpTestHost host, Features.Agent.AgentSession session) + { + await session.PromptAsync("/mcp_list"); + return LastNote(host); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteMcpTestHost.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteMcpTestHost.cs new file mode 100644 index 0000000..c4b91af --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteMcpTestHost.cs @@ -0,0 +1,146 @@ +using System.ComponentModel; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using Volcanic.AgenticCore.Abstractions.Ui; +using Volcanic.AgenticCore.Extensions.Mcp.Remote; +using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support; +using Volcanic.AgenticCore.Features.Agent; +using Volcanic.AgenticCore.Features.Extensions; +using Volcanic.AgenticCore.Features.Sessions; +using Volcanic.AgenticCore.Hosting; +using Volcanic.AgenticCore.Testing; + +namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Remote; + +/// An in-memory Streamable HTTP MCP server that wants as a bearer token. +internal sealed class RemoteTestServer : IAsyncDisposable +{ + public const string Token = "remote-secret-token-123"; + private readonly WebApplication _app; + + private RemoteTestServer(WebApplication app) => _app = app; + + public bool Down { get; set; } + + public static async Task StartAsync() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddMcpServer().WithHttpTransport().WithTools(); + var app = builder.Build(); + RemoteTestServer? server = null; + app.Use(async (context, next) => + { + if (server!.Down) { context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; return; } + if (context.Request.Headers.Authorization != $"Bearer {Token}") { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + await next(context); + }); + app.MapMcp("/mcp"); + await app.StartAsync(); + server = new RemoteTestServer(app); + return server; + } + + public HttpMessageHandler CreateHandler() => _app.GetTestServer().CreateHandler(); + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); +} + +[McpServerToolType] +internal sealed class RemoteTestTools +{ + [McpServerTool(Name = "echo"), Description("Echoes the text")] + public static string Echo(string text) => $"echo: {text}"; + + [McpServerTool(Name = "whoami"), Description("Repeats the Authorization header")] + public static string WhoAmI(IHttpContextAccessor? accessor) => $"you sent {accessor?.HttpContext?.Request.Headers.Authorization}"; + + [McpServerTool(Name = "add"), Description("Adds two numbers")] + public static int Add(int a, int b) => a + b; +} + +/// Answers with and records notifications. +internal sealed class ScriptedUi : IUserInterface +{ + public string? Answer { get; set; } + public List Notes { get; } = []; + public List Questions { get; } = []; + + public Task SelectAsync(string title, IReadOnlyList options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(null); + public Task ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false); + public Task InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) + { + Questions.Add(requestOptions); + return Task.FromResult(Answer); + } + public void Notify(string message, NotifyKind kind = NotifyKind.Info) => Notes.Add(message); + public void SetStatus(string key, string? text) { } +} + +/// A core host with the FauxProvider and over . +internal sealed class RemoteMcpTestHost : IAsyncDisposable +{ + public const string Secret = "host-encryption-secret"; + private readonly ServiceProvider _services; + private readonly string _root = Path.Combine(Path.GetTempPath(), "volcanic-mcp-remote", Guid.NewGuid().ToString("N")); + private readonly List _sessions = []; + + private RemoteMcpTestHost(RemoteTestServer server, string? secret) + { + Server = server; + Directory.CreateDirectory(Workspace); + Options = new RemoteMcpOptions + { + Secret = secret, + HttpHandlerFactory = server.CreateHandler, + ConnectTimeout = TimeSpan.FromSeconds(10), + RetryDelay = TimeSpan.Zero, + }; + var services = new ServiceCollection(); + services.AddSingleton(Logs); + services.AddAgenticCore(o => + { + o.AgentDir = Path.Combine(_root, "agent"); + o.DiscoverExtensionAssemblies = false; + o.DefaultModel = FauxProvider.ModelKey; + o.ProcessEnvironment.HardenHostProcess = false; + o.SessionStoreFactory = _ => new InMemorySessionStore(); + o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension)); + o.Extensions.Add(new ExtensionRegistration(RemoteMcpExtension.ExtensionName, _ => new RemoteMcpExtension(Options), "test")); + }); + _services = services.BuildServiceProvider(); + } + + public static async Task StartAsync(string? secret = Secret) => new(await RemoteTestServer.StartAsync(), secret); + + public RemoteTestServer Server { get; } + public RemoteMcpOptions Options { get; } + public FauxProvider Faux { get; } = new(); + public ScriptedUi Ui { get; } = new(); + public RecordingLoggerFactory Logs { get; } = new(); + public string Workspace => Path.Combine(_root, "ws"); + public string ServerUrl => "http://localhost/mcp"; + + public async Task CreateSessionAsync(string scope = "telegram-1") + { + var session = await _services.GetRequiredService() + .CreateAsync(new AgentSessionOptions { Cwd = Workspace, Scope = scope, Ui = Ui }); + lock (_sessions) _sessions.Add(session); + return session; + } + + public async ValueTask DisposeAsync() + { + foreach (var session in _sessions) await session.DisposeAsync(); + await _services.DisposeAsync(); + await Server.DisposeAsync(); + try { Directory.Delete(_root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteServersFileTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteServersFileTests.cs new file mode 100644 index 0000000..7e247ed --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteServersFileTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Nodes; +using Volcanic.AgenticCore.Extensions.Mcp.Remote; + +namespace Volcanic.AgenticCore.Extensions.Tests.Mcp.Remote; + +public sealed class RemoteServersFileTests : IDisposable +{ + private readonly string _workspace = Path.Combine(Path.GetTempPath(), "volcanic-mcp-file", Guid.NewGuid().ToString("N")); + private readonly RemoteServerCipher _cipher = new("secret-one", "telegram-1"); + + private static RemoteServerEntry Entry(string name = "notes") => + new(name, "https://example.com/mcp", RemoteTransport.Auto, ["echo"], new Dictionary { ["Authorization"] = "Bearer abcdef123456" }); + + public void Dispose() + { + try { Directory.Delete(_workspace, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + + [Fact] + public void Round_trip_keeps_everything_and_the_file_has_no_key() + { + RemoteServersFile.Write(_workspace, _cipher, [Entry()]); + + var read = RemoteServersFile.Read(_workspace, _cipher); + + var entry = Assert.Single(read.Servers); + Assert.Equal("Bearer abcdef123456", entry.Headers["authorization"]); + Assert.Equal(["echo"], entry.Tools); + Assert.Empty(read.Problems); + Assert.DoesNotContain("abcdef123456", File.ReadAllText(RemoteServersFile.PathFor(_workspace)), StringComparison.Ordinal); + Assert.DoesNotContain("abcdef123456", entry.ToString(), StringComparison.Ordinal); + if (!OperatingSystem.IsWindows()) + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(RemoteServersFile.PathFor(_workspace))); + } + + [Theory] + [InlineData("url", "https://attacker.example/mcp")] + [InlineData("tools", null)] + [InlineData("name", "other")] + public void Changing_a_visible_field_breaks_the_seal(string field, string? value) + { + RemoteServersFile.Write(_workspace, _cipher, [Entry()]); + var path = RemoteServersFile.PathFor(_workspace); + var root = JsonNode.Parse(File.ReadAllText(path))!; + root["servers"]![0]![field] = value; + File.WriteAllText(path, root.ToJsonString()); + + var read = RemoteServersFile.Read(_workspace, _cipher); + + Assert.Empty(read.Servers); + Assert.Contains("not added by /mcp_add", Assert.Single(read.Problems), StringComparison.Ordinal); + } + + [Fact] + public void Another_chat_or_another_secret_cannot_open_it() + { + RemoteServersFile.Write(_workspace, _cipher, [Entry()]); + + Assert.Empty(RemoteServersFile.Read(_workspace, new RemoteServerCipher("secret-one", "telegram-2")).Servers); + Assert.Empty(RemoteServersFile.Read(_workspace, new RemoteServerCipher("secret-two", "telegram-1")).Servers); + } + + [Theory] + [InlineData("ftp://example.com/mcp")] + [InlineData("https://user:pass@example.com/mcp")] + [InlineData("not a url")] + public void Bad_urls_are_refused(string url) => Assert.NotNull(RemoteServersFile.CheckUrl(url)); + + [Fact] + public void Display_url_drops_the_query() => + Assert.Equal("https://example.com/mcp", RemoteServersFile.DisplayUrl("https://example.com/mcp?key=abc")); + + [Theory] + [InlineData("tok_123", "Authorization", "Bearer tok_123")] + [InlineData("X-Api-Key: k-123", "X-Api-Key", "k-123")] + public void Key_answers_become_headers(string answer, string header, string value) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + Assert.Null(RemoteMcpCommands.ParseKeyAnswer(answer, headers)); + Assert.Equal(value, headers[header]); + } + + [Fact] + public void Dash_means_no_key_and_spaces_are_refused() + { + var headers = new Dictionary(); + Assert.Null(RemoteMcpCommands.ParseKeyAnswer("-", headers)); + Assert.Empty(headers); + Assert.NotNull(RemoteMcpCommands.ParseKeyAnswer("two words", headers)); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj b/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj index ba834f8..0cb9365 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs index 59fe5e8..4c96043 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs @@ -109,7 +109,7 @@ public class GoogleCalendarHostTests : IDisposable var commands = Assert.Single(bot.Requests.OfType()).Commands.Select(c => c.Command).ToList(); var calendarCommands = new[] { "calendar_connect", "calendar_status", "calendar_disconnect" }; - if (configured) Assert.Equal(calendarCommands, commands.Skip(ChatCommands.Menu.Count).ToArray()); + if (configured) Assert.Equal(calendarCommands, commands.Skip(ChatCommands.Menu.Count).Take(calendarCommands.Length).ToArray()); else Assert.DoesNotContain(commands, c => calendarCommands.Contains(c)); Assert.Equal(ChatCommands.Menu.Select(c => c.Command), commands.Take(ChatCommands.Menu.Count)); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/RemoteMcpChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/RemoteMcpChatTests.cs new file mode 100644 index 0000000..836812d --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/RemoteMcpChatTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using Telegram.Bot.Requests; +using Volcanic.AgenticCore.Abstractions.Ui; +using Volcanic.AgenticCore.Extensions.Mcp.Remote; +using Volcanic.AgenticCore.Telegram.Tests.Support; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public class RemoteMcpChatTests +{ + /// Every request gets a 401: enough to see a refused /mcp_add. + private sealed class UnauthorizedHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + } + + private static RemoteMcpOptions Options() => new() { Secret = "test-secret", HttpHandlerFactory = () => new UnauthorizedHandler() }; + + [Fact] + public async Task Mcp_add_with_an_inline_key_is_deleted_and_never_reaches_the_model() + { + await using var host = new ChatHost(remoteMcp: Options()); + + await host.SendAsync("/mcp_add notes https://mcp.example.com/mcp --bearer very-secret-token"); + await Wait.UntilAsync(() => host.Bot.Requests.OfType().Any(), "the key message deleted"); + await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("Not added", StringComparison.Ordinal)), "the reply"); + + Assert.Empty(host.Faux.Requests); + Assert.DoesNotContain(host.Texts, t => t.Contains("very-secret-token", StringComparison.Ordinal)); + } + + [Fact] + public async Task A_secret_answer_is_deleted_and_an_ordinary_one_is_not() + { + await using var host = new ChatHost(remoteMcp: Options()); + await host.SendAsync("/status"); + var chat = await host.ChatAsync(); + + var plain = chat.Ui.InputAsync("Name?", null, null, CancellationToken.None); + await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the question"); + await host.SendAsync("Ada"); + Assert.Equal("Ada", await plain.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Empty(host.Bot.Requests.OfType()); + + var secret = chat.Ui.InputAsync("Key?", null, new UiRequestOptions(Secret: true), CancellationToken.None); + await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the key question"); + await host.SendAsync("tok-123456789"); + Assert.Equal("tok-123456789", await secret.WaitAsync(TimeSpan.FromSeconds(5))); + await Wait.UntilAsync(() => host.Bot.Requests.OfType().Any(), "the answer deleted"); + Assert.Empty(host.Faux.Requests); + } + + [Fact] + public async Task Help_lists_the_mcp_commands() + { + await using var host = new ChatHost(remoteMcp: Options()); + + await host.SendAsync("/help"); + + await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("/mcp_add", StringComparison.Ordinal)), "the help text"); + } + + [Theory] + [InlineData("mcp_add", "x https://a/mcp --bearer t", true)] + [InlineData("mcp_add", "x https://a/mcp --header \"X-Key: v\"", true)] + [InlineData("mcp_add", "x https://a/mcp", false)] + [InlineData("mcp_list", "--bearer", false)] + public void Only_mcp_add_with_a_key_is_deleted(string command, string args, bool expected) => + Assert.Equal(expected, RemoteMcpSetup.CarriesKey(command, args)); +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs index d28bdd9..4124c50 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs @@ -60,7 +60,7 @@ public sealed class ChatHost : IAsyncDisposable Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null, IScriptRunner? runner = null, TriggerOptions? triggers = null, IJevClient? jev = null, Volcanic.AgenticCore.A2A.Server.A2AHostOptions? a2a = null, Volcanic.AgenticCore.Extensions.A2A.A2AClientSettings? a2aClient = null, - Volcanic.AgenticCore.Telegram.Speech.ITextToSpeech? tts = null) + Volcanic.AgenticCore.Telegram.Speech.ITextToSpeech? tts = null, Volcanic.AgenticCore.Extensions.Mcp.Remote.RemoteMcpOptions? remoteMcp = null) { _ownsRoot = root is null; _root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N")); @@ -118,7 +118,7 @@ public sealed class ChatHost : IAsyncDisposable } if (a2aClient is not null) A2AWatchRegistry = new Volcanic.AgenticCore.Extensions.A2A.A2AWatchRegistry(clock, new HttpClientHandler()); Chats = new ChatRegistry(_services.GetRequiredService(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation, Triggers, - a2aClient, A2AWatchRegistry, tts); + a2aClient, A2AWatchRegistry, tts, remoteMcp); if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger.Instance); var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance, speech); Composer = new InboundComposer(sender, media, NullLogger.Instance);