feat(mcp): remote MCP servers per chat with sealed keys and owner-only commands
Streamable HTTP/SSE servers from the workspace's .volcanic/mcp/servers.json. Headers are sealed with AES-GCM; the chat and the visible fields are the associated data, so only /mcp_add|remove|tools change servers. Tools <server>__<tool> go live in the session. An unreachable server is skipped and reported in the system prompt. Keys are redacted from tool results, and Telegram deletes messages that carry one.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 <name> <url> [--tools a,b] [--transport auto|http|sse] [--bearer <token>] [--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 <name> <url>", (args, ctx, ct) => AddAsync(extension, args, ctx, ct)));
|
||||
api.RegisterCommand(Remove, Command(api, Remove, "Remove a remote MCP server: /mcp_remove <name>", (args, ctx, _) => RemoveAsync(extension, args, ctx)));
|
||||
api.RegisterCommand(Tools, Command(api, Tools, "Show or limit a server's tools: /mcp_tools <name> [a,b|all]", (args, ctx, _) => ToolsAsync(extension, args, ctx)));
|
||||
}
|
||||
|
||||
private static CommandDefinition Command(IExtensionApi api, string name, string description,
|
||||
Func<string, IExtensionCommandContext, CancellationToken, Task<string>> 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 <name> <url>.";
|
||||
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<string> AddAsync(RemoteMcpExtension extension, string args, IExtensionCommandContext ctx, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!extension.IsConfigured) return DisabledReply;
|
||||
IReadOnlyList<string> words;
|
||||
try { words = ShellWords.Split(args); }
|
||||
catch (FormatException ex) { return $"{ex.Message}\n{AddUsage}"; }
|
||||
|
||||
var positional = new List<string>();
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
IReadOnlyList<string>? 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 <token>\"), 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<string> RemoveAsync(RemoteMcpExtension extension, string args, IExtensionCommandContext ctx)
|
||||
{
|
||||
if (!extension.IsConfigured) return DisabledReply;
|
||||
var name = args.Trim();
|
||||
if (name.Length == 0) return "Usage: /mcp_remove <name>";
|
||||
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<string> 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 <name> [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<string> ParseToolList(string text) =>
|
||||
[.. text.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct(StringComparer.Ordinal)];
|
||||
|
||||
/// <summary>"Name: value" with a valid header name; null otherwise.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>"-" (no key), a bare token (Bearer), or "Name: value" lines. Returns a problem, or null with <paramref name="headers"/> filled.</summary>
|
||||
internal static string? ParseKeyAnswer(string answer, Dictionary<string, string> 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Remote MCP servers (Streamable HTTP or SSE) per chat, from the workspace's <c>.volcanic/mcp/servers.json</c>. Only the
|
||||
/// owner's commands (<c>/mcp_add</c>, <c>/mcp_remove</c>, <c>/mcp_tools</c>, <c>/mcp_list</c>) change the servers; the
|
||||
/// agent can't, because entries are sealed with a host secret. Tools appear as <c><server>__<tool></c>. A server
|
||||
/// that can't be reached leaves the session without its tools, and the agent's system prompt says why. Instantiated
|
||||
/// per session.
|
||||
/// </summary>
|
||||
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<string, ServerState> _servers = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, RemoteMcpTool> _tools = new(StringComparer.Ordinal);
|
||||
private IReadOnlyList<string> _fileProblems = [];
|
||||
private IExtensionApi? _api;
|
||||
private ILogger _logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
|
||||
private Func<string, string> _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<SessionStartEvent>(async (_, ctx, _) =>
|
||||
{
|
||||
Track(ctx);
|
||||
await SyncAsync(wait: false).ConfigureAwait(false); // connects in the background; the first run waits for it
|
||||
});
|
||||
api.On<BeforeAgentStartEvent>(async (e, ctx, _) =>
|
||||
{
|
||||
Track(ctx);
|
||||
await SyncAsync(wait: true).ConfigureAwait(false);
|
||||
if (PromptSection() is { } section) e.SystemPromptOptions.Sections[SectionKey] = section;
|
||||
});
|
||||
api.On<ToolResultEvent>((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<SessionShutdownEvent>(async (e, _, _) =>
|
||||
{
|
||||
if (e.Reason is not (SessionShutdownReason.Quit or SessionShutdownReason.Reload)) return; // /new keeps the connections
|
||||
List<ServerState> 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<string> ExposedTools, IReadOnlyList<Tool>? AllTools, string? ServerInfo);
|
||||
|
||||
internal IReadOnlyList<ServerStatus> 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<string> FileProblems
|
||||
{
|
||||
get { lock (_lock) return _fileProblems; }
|
||||
}
|
||||
|
||||
/// <summary>The host's secrets and every header value of this chat's servers become <c>[REDACTED]</c>.</summary>
|
||||
internal string Redact(string text)
|
||||
{
|
||||
List<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>A header value, and its credential alone when it has a scheme ("Bearer abc…" → "abc…"). Short values are left alone.</summary>
|
||||
internal static IEnumerable<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the session in line with <c>servers.json</c>: drops removed servers, connects new or changed ones, retries
|
||||
/// failed ones whose delay has passed. With <paramref name="wait"/> it returns once every connect in flight has ended
|
||||
/// (each is bounded by <see cref="RemoteMcpOptions.ConnectTimeout"/>).
|
||||
/// </summary>
|
||||
internal async Task SyncAsync(bool wait)
|
||||
{
|
||||
if (_cipher is not { } cipher || _cwd is not { } cwd) return;
|
||||
var pending = new List<Task>();
|
||||
await _sync.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var read = RemoteServersFile.Read(cwd, cipher);
|
||||
var problems = read.Error is { } error ? [error, .. read.Problems] : read.Problems;
|
||||
List<ServerState> 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);
|
||||
|
||||
/// <summary>Registers (or re-binds) the allowed tools as <c><server>__<tool></c> and activates them.</summary>
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <summary>What the agent is told: connected servers in one line each, and why the others are missing.</summary>
|
||||
internal string? PromptSection()
|
||||
{
|
||||
IReadOnlyList<ServerStatus> 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 <server>__<tool>.\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<Tool>? AllTools { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public int Failures { get; set; }
|
||||
public DateTimeOffset? RetryAt { get; set; }
|
||||
public List<string> Exposed { get; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp.Remote;
|
||||
|
||||
/// <summary>
|
||||
/// Remote MCP servers per chat (<see cref="RemoteMcpExtension"/>). Process-wide; the servers themselves are per chat.
|
||||
/// Without <see cref="Secret"/> the commands only explain how to turn the feature on.
|
||||
/// </summary>
|
||||
public sealed record RemoteMcpOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>MCP_ENCRYPTION_SECRET</c>, else the bot token). Changing it makes every stored server unreadable.
|
||||
/// </summary>
|
||||
public string? Secret { get; init; }
|
||||
|
||||
/// <summary>Handshake plus <c>tools/list</c>, per server. A server that misses it starts the session without its tools.</summary>
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>One <c>tools/call</c>.</summary>
|
||||
public TimeSpan CallTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>First retry delay for a server that failed to connect; doubles per failure up to <see cref="MaxRetryDelay"/>.</summary>
|
||||
public TimeSpan RetryDelay { get; init; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
public TimeSpan MaxRetryDelay { get; init; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>How long <c>/mcp_add</c> waits for the key when it asks for one.</summary>
|
||||
public TimeSpan KeyPromptTimeout { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Tests pass a handler over an in-memory server; null uses one shared socket handler.</summary>
|
||||
public Func<HttpMessageHandler>? HttpHandlerFactory { get; init; }
|
||||
|
||||
public TimeProvider? Time { get; init; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(Secret);
|
||||
|
||||
public override string ToString() => $"RemoteMcpOptions {{ Configured = {IsConfigured}, ConnectTimeout = {ConnectTimeout} }}";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>The server this tool currently calls, or null once the server is gone.</summary>
|
||||
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<ToolResult> 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// One remote server for one chat session: an SDK client over HTTP (Streamable HTTP, or SSE). Connected by
|
||||
/// <see cref="ConnectAsync"/>; a call that fails in the transport drops the client and the next call connects again.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Connects (if needed) and lists the tools. Throws <see cref="RemoteServerException"/> with a readable message.</summary>
|
||||
public async Task<IReadOnlyList<Tool>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a tool, connecting first if the last call dropped the client. Throws <see cref="TimeoutException"/>,
|
||||
/// <see cref="OperationCanceledException"/>, <see cref="RemoteServerException"/>, or the SDK's protocol exception.
|
||||
/// </summary>
|
||||
public async Task<CallToolResult> 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<CallToolRequestParams, CallToolResult>(
|
||||
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(); }
|
||||
}
|
||||
|
||||
/// <summary>Caller holds the gate.</summary>
|
||||
private async Task<McpClient> 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<string, string>(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;
|
||||
}
|
||||
|
||||
/// <summary>Drops the current client (or only <paramref name="expected"/>, if it is still the current one).</summary>
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/// <summary>The innermost useful message: "Connection refused", "401 (Unauthorized)" and the like.</summary>
|
||||
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<string, JsonElement> ToArguments(JsonElement arguments)
|
||||
{
|
||||
var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
if (arguments.ValueKind == JsonValueKind.Object)
|
||||
foreach (var property in arguments.EnumerateObject()) result[property.Name] = property.Value.Clone();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A remote server could not be reached or broke off; the message is meant for the agent and the owner.</summary>
|
||||
internal sealed class RemoteServerException(string message, Exception? inner = null) : Exception(message, inner);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>How the client talks to a remote server. <see cref="Auto"/> tries Streamable HTTP, then falls back to SSE.</summary>
|
||||
public enum RemoteTransport { Auto, Http, Sse }
|
||||
|
||||
/// <summary>
|
||||
/// One remote MCP server of a chat. <see cref="Headers"/> are sent with every request and live sealed on disk;
|
||||
/// <see cref="ToString"/> shows header names only. <see cref="Tools"/> null means every tool the server offers.
|
||||
/// <see cref="Auth"/> is <c>headers</c> today; another kind (OAuth, say) would keep its tokens in the same sealed part.
|
||||
/// </summary>
|
||||
public sealed record RemoteServerEntry(
|
||||
string Name,
|
||||
string Url,
|
||||
RemoteTransport Transport,
|
||||
IReadOnlyList<string>? Tools,
|
||||
IReadOnlyDictionary<string, string> 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)}] }}";
|
||||
|
||||
/// <summary>Changes when anything that needs a new connection changes (header values included, as a digest).</summary>
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="Problems">Entries that were skipped, described without secrets.</param>
|
||||
/// <param name="Error">The file as a whole could not be read; nothing was loaded.</param>
|
||||
public sealed record RemoteServersRead(IReadOnlyList<RemoteServerEntry> Servers, IReadOnlyList<string> Problems, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// <c><workspace>/.volcanic/mcp/servers.json</c>: the chat's remote MCP servers.
|
||||
/// <code>
|
||||
/// { "version": 1, "servers": [ { "name": "notes", "url": "https://…/mcp", "transport": "auto", "tools": null,
|
||||
/// "auth": "headers", "sealed": "<base64>" } ] }
|
||||
/// </code>
|
||||
/// <c>sealed</c> holds <c>{"headers":{…}}</c> encrypted with <see cref="RemoteServerCipher"/>. 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
|
||||
/// <c>bash</c>, 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Null when valid, else why not (an absolute http(s) URL without user info).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Scheme, host, port and path only: a query or fragment may carry credentials.</summary>
|
||||
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<RemoteServerEntry>();
|
||||
var problems = new List<string>();
|
||||
var seen = new HashSet<string>(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);
|
||||
}
|
||||
|
||||
/// <summary>Replaces the file with <paramref name="servers"/>, every entry sealed afresh.</summary>
|
||||
public static void Write(string workspace, RemoteServerCipher cipher, IEnumerable<RemoteServerEntry> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The visible fields and the chat, bound to the sealed headers: change any of them and the entry no longer opens.</summary>
|
||||
private static byte[] AssociatedData(string scope, string name, string url, RemoteTransport transport, IReadOnlyList<string>? tools, string auth) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new object?[] { "volcanic mcp server v1", scope, name, url, TransportName(transport), tools, auth });
|
||||
|
||||
private static JsonObject HeadersNode(IReadOnlyDictionary<string, string> headers)
|
||||
{
|
||||
var node = new JsonObject();
|
||||
foreach (var (key, value) in headers) node[key] = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string>? 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<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in headers)
|
||||
if (value is JsonValue v && v.TryGetValue<string>(out var s)) result[key] = s;
|
||||
return result;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plain);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryTools(JsonNode? node, out IReadOnlyList<string>? tools)
|
||||
{
|
||||
tools = null;
|
||||
if (node is null) return true;
|
||||
if (node is not JsonArray array) return false;
|
||||
var list = new List<string>();
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is not JsonValue v || !v.TryGetValue<string>(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<string>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM for the sealed part of <c>servers.json</c>. 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The session scope (one per chat); part of every entry's associated data.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Null when the data is damaged, sealed with another secret, or its associated data differs.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset.</Description>
|
||||
<Description>MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset, and remote HTTP/SSE servers per chat.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user