forked from admin/VolcanicAgenticCore
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.
232 lines
13 KiB
C#
232 lines
13 KiB
C#
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();
|
||
}
|