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,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<ToolResultMessage>().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<ToolResultMessage>().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<string> ListAsync(RemoteMcpTestHost host, Features.Agent.AgentSession session)
|
||||
{
|
||||
await session.PromptAsync("/mcp_list");
|
||||
return LastNote(host);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>An in-memory Streamable HTTP MCP server that wants <see cref="Token"/> as a bearer token.</summary>
|
||||
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<RemoteTestServer> StartAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddMcpServer().WithHttpTransport().WithTools<RemoteTestTools>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Answers <see cref="InputAsync"/> with <see cref="Answer"/> and records notifications.</summary>
|
||||
internal sealed class ScriptedUi : IUserInterface
|
||||
{
|
||||
public string? Answer { get; set; }
|
||||
public List<string> Notes { get; } = [];
|
||||
public List<UiRequestOptions?> Questions { get; } = [];
|
||||
|
||||
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
|
||||
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
|
||||
public Task<string?> 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) { }
|
||||
}
|
||||
|
||||
/// <summary>A core host with the FauxProvider and <see cref="RemoteMcpExtension"/> over <see cref="RemoteTestServer"/>.</summary>
|
||||
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<AgentSession> _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<ILoggerFactory>(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<RemoteMcpTestHost> 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<AgentSession> CreateSessionAsync(string scope = "telegram-1")
|
||||
{
|
||||
var session = await _services.GetRequiredService<IAgentSessionFactory>()
|
||||
.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) { }
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> { ["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<string, string>(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<string, string>();
|
||||
Assert.Null(RemoteMcpCommands.ParseKeyAnswer("-", headers));
|
||||
Assert.Empty(headers);
|
||||
Assert.NotNull(RemoteMcpCommands.ParseKeyAnswer("two words", headers));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -109,7 +109,7 @@ public class GoogleCalendarHostTests : IDisposable
|
||||
|
||||
var commands = Assert.Single(bot.Requests.OfType<SetMyCommandsRequest>()).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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Every request gets a 401: enough to see a refused /mcp_add.</summary>
|
||||
private sealed class UnauthorizedHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> 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<DeleteMessageRequest>().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<DeleteMessageRequest>());
|
||||
|
||||
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<DeleteMessageRequest>().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));
|
||||
}
|
||||
@@ -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<IAgentSessionFactory>(), 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<TelegramJobDelivery>.Instance);
|
||||
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
|
||||
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
|
||||
|
||||
Reference in New Issue
Block a user