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.
154 lines
7.0 KiB
C#
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);
|
|
}
|
|
}
|