Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Remote/RemoteMcpExtensionTests.cs
EugeneTes 891a644590 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.
2026-10-09 21:11:01 +00:00

154 lines
7.0 KiB
C#

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);
}
}