Add client-side Streamable HTTP transport support (#356)

This commit is contained in:
Stephen Halter 2025-04-30 17:21:43 -07:00 • committed by GitHub
commit e913d80f05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 908 additions and 464 deletions

View file

@ -0,0 +1,273 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.AspNetCore.Tests;
public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
{
protected readonly SseServerIntegrationTestFixture _fixture;
public HttpServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
_fixture = fixture;
_fixture.Initialize(testOutputHelper, ClientTransportOptions);
}
public override void Dispose()
{
_fixture.TestCompleted();
base.Dispose();
}
protected abstract SseClientTransportOptions ClientTransportOptions { get; }
private Task<IMcpClient> GetClientAsync(McpClientOptions? options = null)
{
return _fixture.ConnectMcpClientAsync(options, LoggerFactory);
}
[Fact]
public async Task ConnectAndPing_Sse_TestServer()
{
// Arrange
// Act
await using var client = await GetClientAsync();
await client.PingAsync(TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(client);
}
[Fact]
public async Task Connect_TestServer_ShouldProvideServerFields()
{
// Arrange
// Act
await using var client = await GetClientAsync();
// Assert
Assert.NotNull(client.ServerCapabilities);
Assert.NotNull(client.ServerInfo);
}
[Fact]
public async Task ListTools_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(tools);
}
[Fact]
public async Task CallTool_Sse_EchoServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
{
["message"] = "Hello MCP!"
},
cancellationToken: TestContext.Current.CancellationToken
);
// assert
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content, c => c.Type == "text");
Assert.Equal("Echo: Hello MCP!", textContent.Text);
}
[Fact]
public async Task ListResources_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
// The everything server provides 100 test resources
Assert.Equal(100, allResources.Count);
}
[Fact]
public async Task ReadResource_Sse_TextResource()
{
// arrange
// act
await using var client = await GetClientAsync();
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
// 1 is index 0, which is "even" in the 0-based index
// We copied this oddity to the test server
var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
TextResourceContents textContent = Assert.IsType<TextResourceContents>(result.Contents[0]);
Assert.NotNull(textContent.Text);
}
[Fact]
public async Task ReadResource_Sse_BinaryResource()
{
// arrange
// act
await using var client = await GetClientAsync();
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
// 2 is index 1, which is "odd" in the 0-based index
// We copied this oddity to the test server
var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
Assert.NotNull(blobContent.Blob);
}
[Fact]
public async Task ListPrompts_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
// assert
Assert.NotNull(prompts);
Assert.NotEmpty(prompts);
// We could add specific assertions for the known prompts
Assert.Contains(prompts, p => p.Name == "simple_prompt");
Assert.Contains(prompts, p => p.Name == "complex_prompt");
}
[Fact]
public async Task GetPrompt_Sse_SimplePrompt()
{
// arrange
// act
await using var client = await GetClientAsync();
var result = await client.GetPromptAsync("simple_prompt", null, cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_Sse_ComplexPrompt()
{
// arrange
// act
await using var client = await GetClientAsync();
var arguments = new Dictionary<string, object?>
{
{ "temperature", "0.7" },
{ "style", "formal" }
};
var result = await client.GetPromptAsync("complex_prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_Sse_NonExistent_ThrowsException()
{
// arrange
// act
await using var client = await GetClientAsync();
await Assert.ThrowsAsync<McpException>(() =>
client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task Sampling_Sse_TestServer()
{
// arrange
// Set up the sampling handler
int samplingHandlerCalls = 0;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
McpClientOptions options = new();
options.Capabilities = new();
options.Capabilities.Sampling ??= new();
options.Capabilities.Sampling.SamplingHandler = async (_, _, _) =>
{
samplingHandlerCalls++;
return new CreateMessageResult
{
Model = "test-model",
Role = Role.Assistant,
Content = new Content
{
Type = "text",
Text = "Test response"
}
};
};
await using var client = await GetClientAsync(options);
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
// Call the server's sampleLLM tool which should trigger our sampling handler
var result = await client.CallToolAsync("sampleLLM", new Dictionary<string, object?>
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
},
cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
var textContent = Assert.Single(result.Content);
Assert.Equal("text", textContent.Type);
Assert.False(string.IsNullOrEmpty(textContent.Text));
}
[Fact]
public async Task CallTool_Sse_EchoServer_Concurrently()
{
await using var client1 = await GetClientAsync();
await using var client2 = await GetClientAsync();
for (int i = 0; i < 4; i++)
{
var client = (i % 2 == 0) ? client1 : client2;
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
{
["message"] = $"Hello MCP! {i}"
},
cancellationToken: TestContext.Current.CancellationToken
);
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content, c => c.Type == "text");
Assert.Equal($"Echo: Hello MCP! {i}", textContent.Text);
}
}
}

View file

@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
namespace ModelContextProtocol.AspNetCore.Tests;
public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper)
{
protected override bool UseStreamableHttp => false;
[Theory]
[InlineData("/a", "/a/sse")]
[InlineData("/a/", "/a/sse")]
[InlineData("/a/b", "/a/b/sse")]
public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePattern, string requestPath)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new()
{
Name = "TestCustomRouteServer",
Version = "1.0.0",
};
}).WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp(routePattern);
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync(requestPath);
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
}
[Fact]
public async Task Can_UseHttpContextAccessor_InTool()
{
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
Builder.Services.AddHttpContextAccessor();
await using var app = Builder.Build();
app.Use(next =>
{
return async context =>
{
context.User = CreateUser("TestUser");
await next(context);
};
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync();
var response = await mcpClient.CallToolAsync(
"EchoWithUserName",
new Dictionary<string, object?>() { ["message"] = "Hello world!" },
cancellationToken: TestContext.Current.CancellationToken);
var content = Assert.Single(response.Content);
Assert.Equal("TestUser: Hello world!", content.Text);
}
}

View file

@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace ModelContextProtocol.AspNetCore.Tests;
public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper)
{
protected override bool UseStreamableHttp => true;
[Theory]
[InlineData("/a", "/a")]
[InlineData("/a", "/a/")]
[InlineData("/a/", "/a/")]
[InlineData("/a/", "/a")]
[InlineData("/a/b", "/a/b")]
public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePattern, string requestPath)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new()
{
Name = "TestCustomRouteServer",
Version = "1.0.0",
};
}).WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp(routePattern);
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync(requestPath);
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
}
}

View file

@ -11,13 +11,18 @@ using System.Security.Claims;
namespace ModelContextProtocol.AspNetCore.Tests;
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
{
private async Task<IMcpClient> ConnectAsync(string? path = "/sse")
protected abstract bool UseStreamableHttp { get; }
protected async Task<IMcpClient> ConnectAsync(string? path = null)
{
path ??= UseStreamableHttp ? "/" : "/sse";
var sseClientTransportOptions = new SseClientTransportOptions()
{
Endpoint = new Uri($"http://localhost{path}"),
UseStreamableHttp = UseStreamableHttp,
};
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
@ -56,65 +61,6 @@ public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTe
Assert.Equal($"data: {pattern}/message", dataLine[..dataLine.IndexOf('?')]);
}
[Theory]
[InlineData("/a", "/a/sse")]
[InlineData("/a/", "/a/sse")]
[InlineData("/a/b", "/a/b/sse")]
public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePattern, string requestPath)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new()
{
Name = "TestCustomRouteServer",
Version = "1.0.0",
};
}).WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp(routePattern);
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync(requestPath);
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
}
[Fact]
public async Task Can_UseHttpContextAccessor_InTool()
{
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
Builder.Services.AddHttpContextAccessor();
await using var app = Builder.Build();
app.Use(next =>
{
return async context =>
{
context.User = CreateUser("TestUser");
await next(context);
};
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync();
var response = await mcpClient.CallToolAsync(
"EchoWithUserName",
new Dictionary<string, object?>() { ["message"] = "Hello world!" },
cancellationToken: TestContext.Current.CancellationToken);
var content = Assert.Single(response.Content);
Assert.Equal("TestUser: Hello world!", content.Text);
}
[Fact]
public async Task Messages_FromNewUser_AreRejected()
{
@ -144,13 +90,13 @@ public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTe
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
}
private ClaimsPrincipal CreateUser(string name)
protected ClaimsPrincipal CreateUser(string name)
=> new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
"TestAuthType", "name", "role"));
[McpServerToolType]
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
{
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
public string EchoWithUserName(string message)

View file

@ -18,10 +18,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
// multiple tests, so this dispatches the output to the current test.
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper = new();
private SseClientTransportOptions DefaultTransportOptions { get; } = new()
private SseClientTransportOptions DefaultTransportOptions { get; set; } = new()
{
Endpoint = new Uri("http://localhost/sse"),
Name = "TestServer",
Endpoint = new("http://localhost/"),
};
public SseServerIntegrationTestFixture()
@ -37,8 +36,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
HttpClient = new HttpClient(socketsHttpHandler)
{
BaseAddress = DefaultTransportOptions.Endpoint,
BaseAddress = new("http://localhost/"),
};
_serverTask = Program.MainAsync([], new XunitLoggerProvider(_delegatingTestOutputHelper), _inMemoryTransport, _stopCts.Token);
}
@ -53,9 +53,10 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
TestContext.Current.CancellationToken);
}
public void Initialize(ITestOutputHelper output)
public void Initialize(ITestOutputHelper output, SseClientTransportOptions clientTransportOptions)
{
_delegatingTestOutputHelper.CurrentTestOutputHelper = output;
DefaultTransportOptions = clientTransportOptions;
}
public void TestCompleted()

View file

@ -1,279 +1,23 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Tests.Utils;
using ModelContextProtocol.Protocol.Transport;
using System.Net;
using System.Text;
namespace ModelContextProtocol.AspNetCore.Tests;
public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
public class SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
: HttpServerIntegrationTests(fixture, testOutputHelper)
{
private readonly SseServerIntegrationTestFixture _fixture;
public SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
protected override SseClientTransportOptions ClientTransportOptions => new()
{
_fixture = fixture;
_fixture.Initialize(testOutputHelper);
}
public override void Dispose()
{
_fixture.TestCompleted();
base.Dispose();
}
private Task<IMcpClient> GetClientAsync(McpClientOptions? options = null)
{
return _fixture.ConnectMcpClientAsync(options, LoggerFactory);
}
[Fact]
public async Task ConnectAndPing_Sse_TestServer()
{
// Arrange
// Act
await using var client = await GetClientAsync();
await client.PingAsync(TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(client);
}
[Fact]
public async Task Connect_TestServer_ShouldProvideServerFields()
{
// Arrange
// Act
await using var client = await GetClientAsync();
// Assert
Assert.NotNull(client.ServerCapabilities);
Assert.NotNull(client.ServerInfo);
}
[Fact]
public async Task ListTools_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(tools);
}
[Fact]
public async Task CallTool_Sse_EchoServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
{
["message"] = "Hello MCP!"
},
cancellationToken: TestContext.Current.CancellationToken
);
// assert
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content, c => c.Type == "text");
Assert.Equal("Echo: Hello MCP!", textContent.Text);
}
[Fact]
public async Task ListResources_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
// The everything server provides 100 test resources
Assert.Equal(100, allResources.Count);
}
[Fact]
public async Task ReadResource_Sse_TextResource()
{
// arrange
// act
await using var client = await GetClientAsync();
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
// 1 is index 0, which is "even" in the 0-based index
// We copied this oddity to the test server
var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
TextResourceContents textContent = Assert.IsType<TextResourceContents>(result.Contents[0]);
Assert.NotNull(textContent.Text);
}
[Fact]
public async Task ReadResource_Sse_BinaryResource()
{
// arrange
// act
await using var client = await GetClientAsync();
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
// 2 is index 1, which is "odd" in the 0-based index
// We copied this oddity to the test server
var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
Assert.NotNull(blobContent.Blob);
}
[Fact]
public async Task ListPrompts_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
// assert
Assert.NotNull(prompts);
Assert.NotEmpty(prompts);
// We could add specific assertions for the known prompts
Assert.Contains(prompts, p => p.Name == "simple_prompt");
Assert.Contains(prompts, p => p.Name == "complex_prompt");
}
[Fact]
public async Task GetPrompt_Sse_SimplePrompt()
{
// arrange
// act
await using var client = await GetClientAsync();
var result = await client.GetPromptAsync("simple_prompt", null, cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_Sse_ComplexPrompt()
{
// arrange
// act
await using var client = await GetClientAsync();
var arguments = new Dictionary<string, object?>
{
{ "temperature", "0.7" },
{ "style", "formal" }
};
var result = await client.GetPromptAsync("complex_prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_Sse_NonExistent_ThrowsException()
{
// arrange
// act
await using var client = await GetClientAsync();
await Assert.ThrowsAsync<McpException>(() =>
client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task Sampling_Sse_TestServer()
{
// arrange
// Set up the sampling handler
int samplingHandlerCalls = 0;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
McpClientOptions options = new();
options.Capabilities = new();
options.Capabilities.Sampling ??= new();
options.Capabilities.Sampling.SamplingHandler = async (_, _, _) =>
{
samplingHandlerCalls++;
return new CreateMessageResult
{
Model = "test-model",
Role = Role.Assistant,
Content = new Content
{
Type = "text",
Text = "Test response"
}
};
};
await using var client = await GetClientAsync(options);
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
// Call the server's sampleLLM tool which should trigger our sampling handler
var result = await client.CallToolAsync("sampleLLM", new Dictionary<string, object?>
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
},
cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
var textContent = Assert.Single(result.Content);
Assert.Equal("text", textContent.Type);
Assert.False(string.IsNullOrEmpty(textContent.Text));
}
[Fact]
public async Task CallTool_Sse_EchoServer_Concurrently()
{
await using var client1 = await GetClientAsync();
await using var client2 = await GetClientAsync();
for (int i = 0; i < 4; i++)
{
var client = (i % 2 == 0) ? client1 : client2;
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
{
["message"] = $"Hello MCP! {i}"
},
cancellationToken: TestContext.Current.CancellationToken
);
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content, c => c.Type == "text");
Assert.Equal($"Echo: Hello MCP! {i}", textContent.Text);
}
}
Endpoint = new Uri("http://localhost/sse"),
Name = "TestServer",
};
[Fact]
public async Task EventSourceResponse_Includes_ExpectedHeaders()
{
using var sseResponse = await _fixture.HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
using var sseResponse = await _fixture.HttpClient.GetAsync("/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
sseResponse.EnsureSuccessStatusCode();
@ -289,7 +33,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
{
// Simulate our own MCP client handshake using a plain HttpClient so we can look for "event: message"
// in the raw SSE response stream which is not exposed by the real MCP client.
await using var sseResponse = await _fixture.HttpClient.GetStreamAsync("", TestContext.Current.CancellationToken);
await using var sseResponse = await _fixture.HttpClient.GetStreamAsync("/sse", TestContext.Current.CancellationToken);
using var streamReader = new StreamReader(sseResponse);
var endpointEvent = await streamReader.ReadLineAsync(TestContext.Current.CancellationToken);
@ -305,7 +49,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
""";
using (var initializeRequestBody = new StringContent(initializeRequest, Encoding.UTF8, "application/json"))
{
var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializeRequestBody, TestContext.Current.CancellationToken);
using var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializeRequestBody, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
@ -314,7 +58,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
""";
using (var initializedNotificationBody = new StringContent(initializedNotification, Encoding.UTF8, "application/json"))
{
var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializedNotificationBody, TestContext.Current.CancellationToken);
using var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializedNotificationBody, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}

View file

@ -0,0 +1,167 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore.Tests;
public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private WebApplication? _app;
private async Task StartAsync()
{
Builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
});
_app = Builder.Build();
var echoTool = McpServerTool.Create(Echo, new()
{
Services = _app.Services,
});
_app.MapPost("/mcp", async (JsonRpcMessage message) =>
{
if (message is not JsonRpcRequest request)
{
// Ignore all non-request notifications.
return Results.Accepted();
}
if (request.Method == "initialize")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "2024-11-05",
Capabilities = new()
{
Tools = new(),
},
ServerInfo = new Implementation
{
Name = "my-mcp",
Version = "0.0.1",
},
}, McpJsonUtilities.DefaultOptions)
});
}
if (request.Method == "tools/list")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new ListToolsResult
{
Tools = [echoTool.ProtocolTool]
}, McpJsonUtilities.DefaultOptions),
});
}
if (request.Method == "tools/call")
{
var parameters = JsonSerializer.Deserialize(request.Params, GetJsonTypeInfo<CallToolRequestParams>());
Assert.NotNull(parameters?.Arguments);
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new CallToolResponse()
{
Content = [new() { Text = parameters.Arguments["message"].ToString() }],
}, McpJsonUtilities.DefaultOptions),
});
}
throw new Exception("Unexpected message!");
});
await _app.StartAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task CanCallToolOnSessionlessStreamableHttpServer()
{
await StartAsync();
await using var transport = new SseClientTransport(new()
{
Endpoint = new("http://localhost/mcp"),
UseStreamableHttp = true,
}, HttpClient, LoggerFactory);
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
var echoTool = Assert.Single(tools);
Assert.Equal("echo", echoTool.Name);
await CallEchoAndValidateAsync(echoTool);
}
[Fact]
public async Task CanCallToolConcurrently()
{
await StartAsync();
await using var transport = new SseClientTransport(new()
{
Endpoint = new("http://localhost/mcp"),
UseStreamableHttp = true,
}, HttpClient, LoggerFactory);
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
var echoTool = Assert.Single(tools);
Assert.Equal("echo", echoTool.Name);
var echoTasks = new Task[100];
for (int i = 0; i < echoTasks.Length; i++)
{
echoTasks[i] = CallEchoAndValidateAsync(echoTool);
}
await Task.WhenAll(echoTasks);
}
private static async Task CallEchoAndValidateAsync(McpClientTool echoTool)
{
var response = await echoTool.CallAsync(new Dictionary<string, object?>() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(response);
var content = Assert.Single(response.Content);
Assert.Equal("text", content.Type);
Assert.Equal("Hello world!", content.Text);
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
[McpServerTool(Name = "echo")]
private static string Echo(string message)
{
return message;
}
}

View file

@ -0,0 +1,638 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Microsoft.Net.Http.Headers;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using ModelContextProtocol.Utils.Json;
using System.Net;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore.Tests;
public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private static McpServerTool[] Tools { get; } = [
McpServerTool.Create(EchoAsync),
McpServerTool.Create(LongRunningAsync),
McpServerTool.Create(Progress),
McpServerTool.Create(Throw),
];
private WebApplication? _app;
private async Task StartAsync()
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
{
Name = nameof(StreamableHttpServerConformanceTests),
Version = "73",
};
}).WithTools(Tools).WithHttpTransport();
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
[Fact]
public async Task NegativeNonInfiniteIdleTimeout_Throws_ArgumentOutOfRangeException()
{
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = TimeSpan.MinValue;
});
var ex = await Assert.ThrowsAnyAsync<ArgumentOutOfRangeException>(StartAsync);
Assert.Contains("IdleTimeout", ex.Message);
}
[Fact]
public async Task NegativeMaxIdleSessionCount_Throws_ArgumentOutOfRangeException()
{
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.MaxIdleSessionCount = -1;
});
var ex = await Assert.ThrowsAnyAsync<ArgumentOutOfRangeException>(StartAsync);
Assert.Contains("MaxIdleSessionCount", ex.Message);
}
[Fact]
public async Task InitialPostResponse_Includes_McpSessionIdHeader()
{
await StartAsync();
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Single(response.Headers.GetValues("mcp-session-id"));
Assert.Equal("text/event-stream", Assert.Single(response.Content.Headers.GetValues("content-type")));
}
[Fact]
public async Task PostRequest_IsUnsupportedMediaType_WithoutJsonContentType()
{
await StartAsync();
using var response = await HttpClient.PostAsync("", new StringContent(InitializeRequest, Encoding.UTF8, "text/javascript"), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}
[Theory]
[InlineData("text/event-stream")]
[InlineData("application/json")]
[InlineData("application/json-text/event-stream")]
public async Task PostRequest_IsNotAcceptable_WithSingleAcceptHeader(string singleAcceptValue)
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Accept.Clear();
HttpClient.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Accept, singleAcceptValue);
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode);
}
[Fact]
public async Task GetRequest_IsNotAcceptable_WithoutTextEventStreamAcceptHeader()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Accept.Clear();
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode);
}
[Fact]
public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
{
await StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Post, "")
{
Content = JsonContent(EchoRequest),
Headers =
{
{ "mcp-session-id", "fakeSession" },
},
};
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task InitializeRequest_Matches_CustomRoute()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp("/custom-route");
await app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
using var response = await HttpClient.PostAsync("/custom-route", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task PostWithSingleNotification_IsAccepted_WithEmptyResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
var response = await HttpClient.PostAsync("", JsonContent(ProgressNotification("1")), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Equal("", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task InitializeJsonRpcRequest_IsHandled_WithCompleteSseResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
}
[Fact]
public async Task BatchedJsonRpcRequests_IsHandled_WithCompleteSseResponse()
{
await StartAsync();
using var response = await HttpClient.PostAsync("", JsonContent($"[{InitializeRequest},{EchoRequest}]"), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var eventCount = 0;
await foreach (var sseEvent in ReadSseAsync(response.Content))
{
var jsonRpcResponse = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcResponse>());
Assert.NotNull(jsonRpcResponse);
var responseId = Assert.IsType<long>(jsonRpcResponse.Id.Id);
switch (responseId)
{
case 1:
AssertServerInfo(jsonRpcResponse);
break;
case 2:
AssertEchoResponse(jsonRpcResponse);
break;
default:
throw new Exception($"Unexpected response ID: {jsonRpcResponse.Id}");
}
eventCount++;
}
Assert.Equal(2, eventCount);
}
[Fact]
public async Task SingleJsonRpcRequest_ThatThrowsIsHandled_WithCompleteSseResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
var response = await HttpClient.PostAsync("", JsonContent(CallTool("throw")), TestContext.Current.CancellationToken);
var rpcError = await AssertSingleSseResponseAsync(response);
var error = AssertType<CallToolResponse>(rpcError.Result);
var content = Assert.Single(error.Content);
Assert.Contains("'throw'", content.Text);
}
[Fact]
public async Task MultipleSerialJsonRpcRequests_IsHandled_OneAtATime()
{
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
await CallEchoAndValidateAsync();
}
[Fact]
public async Task MultipleConcurrentJsonRpcRequests_IsHandled_InParallel()
{
await StartAsync();
await CallInitializeAndValidateAsync();
var echoTasks = new Task[100];
for (int i = 0; i < echoTasks.Length; i++)
{
echoTasks[i] = CallEchoAndValidateAsync();
}
await Task.WhenAll(echoTasks);
}
[Fact]
public async Task GetRequest_Receives_UnsolicitedNotifications()
{
IMcpServer? server = null;
Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
{
server = mcpServer;
return mcpServer.RunAsync(cancellationToken);
};
});
await StartAsync();
await CallInitializeAndValidateAsync();
Assert.NotNull(server);
// Headers should be sent even before any messages are ready on the GET endpoint.
using var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
async Task<string> GetFirstNotificationAsync()
{
await foreach (var sseEvent in ReadSseAsync(getResponse.Content))
{
var notification = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcNotification>());
Assert.NotNull(notification);
return notification.Method;
}
throw new Exception("No notifications received.");
}
await server.SendNotificationAsync("test-method", TestContext.Current.CancellationToken);
Assert.Equal("test-method", await GetFirstNotificationAsync());
}
[Fact]
public async Task SecondGetRequests_IsRejected_AsBadRequest()
{
await StartAsync();
await CallInitializeAndValidateAsync();
using var getResponse1 = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
using var getResponse2 = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse1.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, getResponse2.StatusCode);
}
[Fact]
public async Task DeleteRequest_CompletesSession_WhichIsNoLongerFound()
{
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task DeleteRequest_CompletesSession_WhichCancelsLongRunningToolCalls()
{
await StartAsync();
await CallInitializeAndValidateAsync();
Task<HttpResponseMessage> CallLongRunningToolAsync() =>
HttpClient.PostAsync("", JsonContent(CallTool("long-running")), TestContext.Current.CancellationToken);
var longRunningToolTasks = new Task<HttpResponseMessage>[10];
for (int i = 0; i < longRunningToolTasks.Length; i++)
{
longRunningToolTasks[i] = CallLongRunningToolAsync();
}
var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
for (int i = 0; i < longRunningToolTasks.Length; i++)
{
Assert.False(longRunningToolTasks[i].IsCompleted);
}
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
// Get request should complete gracefully.
var sseResponseBody = await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Empty(sseResponseBody);
// Currently, the OCE thrown by the canceled session is unhandled and turned into a 500 error by Kestrel.
// The spec suggests sending CancelledNotifications. That would be good, but we can do that later.
// For now, the important thing is that request completes without indicating success.
await Task.WhenAll(longRunningToolTasks);
foreach (var task in longRunningToolTasks)
{
var response = await task;
Assert.False(response.IsSuccessStatusCode);
}
}
[Fact]
public async Task Progress_IsReported_InSameSseResponseAsRpcResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
using var response = await HttpClient.PostAsync("", JsonContent(CallToolWithProgressToken("progress")), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var currentSseItem = 0;
await foreach (var sseEvent in ReadSseAsync(response.Content))
{
currentSseItem++;
if (currentSseItem <= 10)
{
var notification = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcNotification>());
var progressNotification = AssertType<ProgressNotification>(notification?.Params);
Assert.Equal($"Progress {currentSseItem - 1}", progressNotification.Progress.Message);
}
else
{
var rpcResponse = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcResponse>());
var callToolResponse = AssertType<CallToolResponse>(rpcResponse?.Result);
var callToolContent = Assert.Single(callToolResponse.Content);
Assert.Equal("text", callToolContent.Type);
Assert.Equal("done", callToolContent.Text);
}
}
Assert.Equal(11, currentSseItem);
}
[Fact]
public async Task IdleSessions_ArePruned_AfterIdleTimeout()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
Assert.Equal(TimeSpan.FromHours(2), options.IdleTimeout);
options.TimeProvider = fakeTimeProvider;
});
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
// Add 5 seconds to idle timeout to account for the interval of the PeriodicTimer.
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task IdleSessions_AreNotPruned_WithInfiniteIdleTimeoutWhileUnderMaxIdleSessionCount()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = Timeout.InfiniteTimeSpan;
options.TimeProvider = fakeTimeProvider;
});
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
fakeTimeProvider.Advance(TimeSpan.FromDays(1));
// Echo still works because the session has not been pruned.
await CallEchoAndValidateAsync();
}
[Fact]
public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirstDespiteIdleTimeout()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = Timeout.InfiniteTimeSpan;
options.MaxIdleSessionCount = 2;
options.TimeProvider = fakeTimeProvider;
});
var mockLoggerProvider = new MockLoggerProvider();
Builder.Logging.AddProvider(mockLoggerProvider);
await StartAsync();
// Start first session.
var firstSessionId = await CallInitializeAndValidateAsync();
// Start a second session to trigger pruning of the original session.
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
var secondSessionId = await CallInitializeAndValidateAsync();
Assert.NotEqual(firstSessionId, secondSessionId);
// First session ID still works, since we allow up to 2 idle sessions.
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
SetSessionId(firstSessionId);
await CallEchoAndValidateAsync();
// Start a third session to trigger pruning of the first session.
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
var thirdSessionId = await CallInitializeAndValidateAsync();
Assert.NotEqual(secondSessionId, thirdSessionId);
// Pruning of the second session results in a 404 since we used the first session more recently.
fakeTimeProvider.Advance(TimeSpan.FromSeconds(10));
SetSessionId(secondSessionId);
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
// But the first and third session IDs should still work.
SetSessionId(firstSessionId);
await CallEchoAndValidateAsync();
SetSessionId(thirdSessionId);
await CallEchoAndValidateAsync();
var logMessage = Assert.Single(mockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Critical);
Assert.StartsWith("Exceeded maximum of 2 idle sessions.", logMessage.Message);
}
private static StringContent JsonContent(string json) => new StringContent(json, Encoding.UTF8, "application/json");
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private static T AssertType<T>(JsonNode? jsonNode)
{
var type = JsonSerializer.Deserialize<T>(jsonNode, GetJsonTypeInfo<T>());
Assert.NotNull(type);
return type;
}
private static async IAsyncEnumerable<string> ReadSseAsync(HttpContent responseContent)
{
var responseStream = await responseContent.ReadAsStreamAsync(TestContext.Current.CancellationToken);
await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken))
{
Assert.Equal("message", sseItem.EventType);
yield return sseItem.Data;
}
}
private static async Task<JsonRpcResponse> AssertSingleSseResponseAsync(HttpResponseMessage response)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
var sseItem = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken));
var jsonRpcResponse = JsonSerializer.Deserialize(sseItem, GetJsonTypeInfo<JsonRpcResponse>());
Assert.NotNull(jsonRpcResponse);
return jsonRpcResponse;
}
private static string InitializeRequest => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
""";
private long _lastRequestId = 1;
private string EchoRequest
{
get
{
var id = Interlocked.Increment(ref _lastRequestId);
return $$$$"""
{"jsonrpc":"2.0","id":{{{{id}}}},"method":"tools/call","params":{"name":"echo","arguments":{"message":"Hello world! ({{{{id}}}})"}}}
""";
}
}
private string ProgressNotification(string progress)
{
return $$$"""
{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"","progress":{{{progress}}}}}
""";
}
private string Request(string method, string parameters = "{}")
{
var id = Interlocked.Increment(ref _lastRequestId);
return $$"""
{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}}
""";
}
private string CallTool(string toolName, string arguments = "{}") =>
Request("tools/call", $$"""
{"name":"{{toolName}}","arguments":{{arguments}}}
""");
private string CallToolWithProgressToken(string toolName, string arguments = "{}") =>
Request("tools/call", $$$"""
{"name":"{{{toolName}}}","arguments":{{{arguments}}},"_meta":{"progressToken":"abc123"}}
""");
private static InitializeResult AssertServerInfo(JsonRpcResponse rpcResponse)
{
var initializeResult = AssertType<InitializeResult>(rpcResponse.Result);
Assert.Equal(nameof(StreamableHttpServerConformanceTests), initializeResult.ServerInfo.Name);
Assert.Equal("73", initializeResult.ServerInfo.Version);
return initializeResult;
}
private static CallToolResponse AssertEchoResponse(JsonRpcResponse rpcResponse)
{
var callToolResponse = AssertType<CallToolResponse>(rpcResponse.Result);
var callToolContent = Assert.Single(callToolResponse.Content);
Assert.Equal("text", callToolContent.Type);
Assert.Equal($"Hello world! ({rpcResponse.Id})", callToolContent.Text);
return callToolResponse;
}
private async Task<string> CallInitializeAndValidateAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
var rpcResponse = await AssertSingleSseResponseAsync(response);
AssertServerInfo(rpcResponse);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
SetSessionId(sessionId);
return sessionId;
}
private void SetSessionId(string sessionId)
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
}
private async Task CallEchoAndValidateAsync()
{
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
var rpcResponse = await AssertSingleSseResponseAsync(response);
AssertEchoResponse(rpcResponse);
}
[McpServerTool(Name = "echo")]
private static async Task<string> EchoAsync(string message)
{
// McpSession.ProcessMessagesAsync() already yields before calling any handlers, but this makes it even
// more explicit that we're not relying on synchronous execution of the tool.
await Task.Yield();
return message;
}
[McpServerTool(Name = "long-running")]
private static async Task LongRunningAsync(CancellationToken cancellation)
{
// McpSession.ProcessMessagesAsync() already yields before calling any handlers, but this makes it even
// more explicit that we're not relying on synchronous execution of the tool.
await Task.Delay(Timeout.Infinite, cancellation);
}
[McpServerTool(Name = "progress")]
public static string Progress(IProgress<ProgressNotificationValue> progress)
{
for (int i = 0; i < 10; i++)
{
progress.Report(new() { Progress = i, Total = 10, Message = $"Progress {i}" });
}
return "done";
}
[McpServerTool(Name = "throw")]
private static void Throw()
{
throw new Exception();
}
}

View file

@ -0,0 +1,64 @@
using ModelContextProtocol.Protocol.Transport;
using System.Net;
using System.Text;
namespace ModelContextProtocol.AspNetCore.Tests;
public class StreamableHttpServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
: HttpServerIntegrationTests(fixture, testOutputHelper)
{
private const string InitializeRequest = """
{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
""";
protected override SseClientTransportOptions ClientTransportOptions => new()
{
Endpoint = new Uri("http://localhost/"),
Name = "TestServer",
UseStreamableHttp = true,
};
[Fact]
public async Task EventSourceResponse_Includes_ExpectedHeaders()
{
using var initializeRequestBody = new StringContent(InitializeRequest, Encoding.UTF8, "application/json");
using var postRequest = new HttpRequestMessage(HttpMethod.Post, "/")
{
Headers =
{
Accept = { new("application/json"), new("text/event-stream")}
},
Content = initializeRequestBody,
};
using var sseResponse = await _fixture.HttpClient.SendAsync(postRequest, TestContext.Current.CancellationToken);
sseResponse.EnsureSuccessStatusCode();
Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType);
Assert.Equal("identity", sseResponse.Content.Headers.ContentEncoding.ToString());
Assert.NotNull(sseResponse.Headers.CacheControl);
Assert.True(sseResponse.Headers.CacheControl.NoStore);
Assert.True(sseResponse.Headers.CacheControl.NoCache);
}
[Fact]
public async Task EventSourceStream_Includes_MessageEventType()
{
using var initializeRequestBody = new StringContent(InitializeRequest, Encoding.UTF8, "application/json");
using var postRequest = new HttpRequestMessage(HttpMethod.Post, "/")
{
Headers =
{
Accept = { new("application/json"), new("text/event-stream")}
},
Content = initializeRequestBody,
};
using var sseResponse = await _fixture.HttpClient.SendAsync(postRequest, TestContext.Current.CancellationToken);
using var sseResponseStream = await sseResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
using var streamReader = new StreamReader(sseResponseStream);
var messageEvent = await streamReader.ReadLineAsync(TestContext.Current.CancellationToken);
Assert.Equal("event: message", messageEvent);
}
}