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