csharp-sdk/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs
Stephen Halter 324ccd83c3
Relax legacy request metadata validation (#1852)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
2026-09-13 16:30:42 -07:00

214 lines
11 KiB
C#

#if !NET472
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Wire-format conformance tests for <see cref="McpServer"/> driven directly against the underlying
/// stream — without going through <see cref="ModelContextProtocol.Client.McpClient"/>. This exercises the
/// SEP-2575 (no initialize handshake) and SEP-2567 (server/discover, no session id) flows by hand-crafting JSON-RPC
/// messages and asserting on the exact responses the server emits.
/// </summary>
/// <remarks>
/// The tests use a paired <see cref="Pipe"/> the way <see cref="ClientServerTestBase"/> does, but instead
/// of constructing an <c>McpClient</c> we read and write JSON-RPC envelopes directly. This is the closest
/// approximation we have to a third-party / non-SDK client and is what conformance tooling will exercise.
/// </remarks>
public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable
{
private readonly Pipe _clientToServer = new();
private readonly Pipe _serverToClient = new();
private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
private readonly Task _serverTask;
private readonly ServiceProvider _services;
private readonly StreamReader _reader;
private readonly StreamWriter _writer;
public RawStreamConformanceTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
var services = new ServiceCollection();
services.AddLogging(b => b.AddProvider(XunitLoggerProvider));
services
.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = "raw-conformance-server", Version = "1.0.0" };
})
.WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream())
.WithTools([
McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" }),
]);
_services = services.BuildServiceProvider(validateScopes: true);
var server = _services.GetRequiredService<McpServer>();
_serverTask = server.RunAsync(_cts.Token);
_writer = new StreamWriter(_clientToServer.Writer.AsStream(), new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" };
_reader = new StreamReader(_serverToClient.Reader.AsStream(), Encoding.UTF8);
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
_clientToServer.Writer.Complete();
_serverToClient.Writer.Complete();
try { await _serverTask; } catch { /* expected on cancellation */ }
await _services.DisposeAsync();
_cts.Dispose();
Dispose();
}
private async Task SendAsync(string json) => await _writer.WriteLineAsync(json);
private async Task<JsonNode> ReadAsync()
{
var line = await _reader.ReadLineAsync(_cts.Token).ConfigureAwait(false);
Assert.NotNull(line);
return JsonNode.Parse(line!)!;
}
private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) =>
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
[Fact]
public async Task ServerDiscover_ReturnsSupportedVersionsIncludingJuly2026Protocol()
{
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}");
var response = await ReadAsync();
Assert.Equal("2.0", response["jsonrpc"]!.GetValue<string>());
Assert.Equal(1, response["id"]!.GetValue<int>());
var result = response["result"];
Assert.NotNull(result);
var supportedVersions = result!["supportedVersions"]!.AsArray()
.Select(n => n!.GetValue<string>())
.ToList();
Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supportedVersions);
// Capabilities are mandatory in DiscoverResult; server identity is result metadata.
Assert.NotNull(result["capabilities"]);
Assert.Null(result["serverInfo"]);
Assert.Equal("raw-conformance-server", result["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue<string>());
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
Assert.Equal(JsonValueKind.Number, result["ttlMs"]!.GetValueKind());
Assert.Equal(0, result["ttlMs"]!.GetValue<long>());
Assert.Equal("private", result["cacheScope"]!.GetValue<string>());
}
[Fact]
public async Task July2026ProtocolToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided()
{
// Spec: under SEP-2575 the client may skip server/discover and go straight to a normal RPC, as long
// as every request carries the full _meta envelope with protocolVersion, clientInfo and capabilities.
await SendAsync(
@"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hello""}," +
July2026ProtocolMetaFragment() + "}}");
var response = await ReadAsync();
Assert.Equal(42, response["id"]!.GetValue<int>());
var result = response["result"];
Assert.NotNull(result);
var content = result!["content"]!.AsArray();
Assert.Single(content);
Assert.Equal("echo:hello", content[0]!["text"]!.GetValue<string>());
Assert.Equal("raw-conformance-server", result["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue<string>());
}
[Fact]
public async Task July2026ProtocolRequest_WithUnsupportedProtocolVersion_ReturnsMinus32022WithSupported()
{
// Server should respond with UnsupportedProtocolVersionError (-32022) and a data.supported[] list.
await SendAsync(
@"{""jsonrpc"":""2.0"",""id"":7,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," +
July2026ProtocolMetaFragment("9999-99-99") + "}}");
var response = await ReadAsync();
Assert.Equal(7, response["id"]!.GetValue<int>());
var error = response["error"];
Assert.NotNull(error);
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error!["code"]!.GetValue<int>());
var data = error["data"];
Assert.NotNull(data);
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
}
[Fact]
public async Task InitializeHandshake_StillWorks_OnJuly2026ProtocolDefaultServer()
{
// Dual-path: a default server must still accept the initialize handshake from clients that
// don't speak the 2026-07-28 per-request metadata path.
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}");
var response = await ReadAsync();
Assert.Equal(1, response["id"]!.GetValue<int>());
var result = response["result"];
Assert.NotNull(result);
Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue<string>());
Assert.Null(result["_meta"]?[MetaKeys.ServerInfo]);
}
[Fact]
public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed()
{
// Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails.
// This is the sequence a permissive client emits against an unknown server: the failed probe never
// establishes a version, so initialize is still available.
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}");
var discover = await ReadAsync();
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue<int>());
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}");
var init = await ReadAsync();
Assert.NotNull(init["result"]);
Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue<string>());
await SendAsync(@"{""jsonrpc"":""2.0"",""method"":""notifications/initialized"",""params"":{}}");
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""after-init""}}}");
var call = await ReadAsync();
Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
}
[Theory]
[InlineData("tools/list")]
[InlineData("server/discover")]
public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod)
{
// A modern request locks the session's protocol version. initialize declares nothing on stdio, so
// without this gate it would force the session back to a handshake version and make the modern
// _meta on in-flight requests opaque.
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}");
var established = await ReadAsync();
Assert.NotNull(established["result"]);
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}");
var init = await ReadAsync();
Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue<int>());
// The session is still modern: a request carrying full per-request metadata keeps working.
await SendAsync(
@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," +
July2026ProtocolMetaFragment() + "}}");
var call = await ReadAsync();
Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
}
}
#endif