Add stateless doc, prefer stateless mode and disable legacy SSE by default (#1468)
This commit is contained in:
parent
49bf17077d
commit
8eb4023c20
7 changed files with 1440 additions and 73 deletions
|
|
@ -11,14 +11,14 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
|
||||
/// <summary>
|
||||
/// Integration tests for MCP Tasks feature over HTTP transports.
|
||||
/// Tests task creation, polling, cancellation, and result retrieval across SSE streams.
|
||||
/// Tests task creation, polling, cancellation, and result retrieval.
|
||||
/// </summary>
|
||||
public class HttpTaskIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
|
||||
{
|
||||
private readonly HttpClientTransportOptions DefaultTransportOptions = new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/sse"),
|
||||
Name = "In-memory SSE Client",
|
||||
Endpoint = new("http://localhost:5000/"),
|
||||
Name = "In-memory Streamable HTTP Client",
|
||||
};
|
||||
|
||||
private Task<McpClient> ConnectMcpClientAsync(
|
||||
|
|
|
|||
|
|
@ -10,12 +10,18 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
|
|||
protected override bool UseStreamableHttp => false;
|
||||
protected override bool Stateless => false;
|
||||
|
||||
protected override void ConfigureStateless(HttpServerTransportOptions options)
|
||||
{
|
||||
base.ConfigureStateless(options);
|
||||
options.EnableLegacySse = true;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/mcp")]
|
||||
[InlineData("/mcp/secondary")]
|
||||
public async Task Allows_Customizing_Route(string pattern)
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true);
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(pattern);
|
||||
|
|
@ -47,7 +53,7 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
|
|||
Name = "TestCustomRouteServer",
|
||||
Version = "1.0.0",
|
||||
};
|
||||
}).WithHttpTransport();
|
||||
}).WithHttpTransport(options => options.EnableLegacySse = true);
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(routePattern);
|
||||
|
|
@ -77,7 +83,7 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
|
|||
return "Complete";
|
||||
}, options: new() { Name = "polling_tool" });
|
||||
|
||||
Builder.Services.AddMcpServer().WithHttpTransport().WithTools([pollingTool]);
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true).WithTools([pollingTool]);
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using ModelContextProtocol.Protocol;
|
|||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
|
@ -96,6 +97,42 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
|||
Assert.Equal("AutoDetectTestServer", mcpClient.ServerInfo.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SseEndpoints_AreDisabledByDefault_InStatefulMode()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||
{
|
||||
// Stateful mode, but SSE not explicitly enabled.
|
||||
options.Stateless = false;
|
||||
});
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var sseResponse = await HttpClient.GetAsync("/sse", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, sseResponse.StatusCode);
|
||||
|
||||
using var messageResponse = await HttpClient.PostAsync("/message", new StringContent(""), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, messageResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SseEndpoints_ThrowOnMapMcp_InStatelessMode_WithEnableLegacySse()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||
{
|
||||
options.Stateless = true;
|
||||
options.EnableLegacySse = true;
|
||||
});
|
||||
await using var app = Builder.Build();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => app.MapMcp());
|
||||
Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("EnableLegacySse", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoDetectMode_Works_WithSseEndpoint()
|
||||
{
|
||||
|
|
@ -108,7 +145,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
|||
Name = "AutoDetectSseTestServer",
|
||||
Version = "1.0.0",
|
||||
};
|
||||
}).WithHttpTransport(ConfigureStateless);
|
||||
}).WithHttpTransport(options => { ConfigureStateless(options); options.EnableLegacySse = true; });
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp();
|
||||
|
|
@ -136,7 +173,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
|||
Name = "SseTestServer",
|
||||
Version = "1.0.0",
|
||||
};
|
||||
}).WithHttpTransport(ConfigureStateless);
|
||||
}).WithHttpTransport(options => { ConfigureStateless(options); options.EnableLegacySse = true; });
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp();
|
||||
|
|
@ -551,4 +588,152 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
|||
// Dispose should still not hang
|
||||
await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_CanReconnect_AfterSessionExpiry()
|
||||
{
|
||||
Assert.SkipWhen(Stateless, "Sessions don't exist in stateless mode.");
|
||||
|
||||
string? expiredSessionId = null;
|
||||
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<ClaimsPrincipalTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
// Middleware that returns 404 for the expired session, simulating server-side session expiry.
|
||||
app.Use(next =>
|
||||
{
|
||||
return async context =>
|
||||
{
|
||||
if (expiredSessionId is not null &&
|
||||
context.Request.Headers["Mcp-Session-Id"].ToString() == expiredSessionId)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
await next(context);
|
||||
};
|
||||
});
|
||||
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Connect the first client and verify it works.
|
||||
var client1 = await ConnectAsync();
|
||||
var originalSessionId = client1.SessionId;
|
||||
Assert.NotNull(originalSessionId);
|
||||
|
||||
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotEmpty(tools);
|
||||
|
||||
// Simulate session expiry by having the middleware reject the original session.
|
||||
expiredSessionId = originalSessionId;
|
||||
|
||||
// The next request should fail.
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
// Completion should resolve with a 404 status code.
|
||||
var details = await client1.Completion.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
|
||||
Assert.Equal(HttpStatusCode.NotFound, httpDetails.HttpStatusCode);
|
||||
|
||||
await client1.DisposeAsync();
|
||||
|
||||
// Reconnect with a brand-new session.
|
||||
await using var client2 = await ConnectAsync();
|
||||
Assert.NotNull(client2.SessionId);
|
||||
Assert.NotEqual(originalSessionId, client2.SessionId);
|
||||
|
||||
// The new session works normally.
|
||||
tools = await client2.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotEmpty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler()
|
||||
{
|
||||
var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>();
|
||||
var capturedActivityTags = new ConcurrentBag<(string? TagValue, bool HadActivity, string Method)>();
|
||||
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
// This is the pattern documented in sessions.md — verify it actually works.
|
||||
// Tag before next() so child spans inherit the value.
|
||||
app.MapMcp().AddEndpointFilter(async (context, next) =>
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
|
||||
// Read from request headers — available on all non-initialize requests in stateful mode.
|
||||
string? beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"];
|
||||
|
||||
// Tag before next() so child activities created during the handler inherit it.
|
||||
var activity = System.Diagnostics.Activity.Current;
|
||||
if (beforeSessionId != null)
|
||||
{
|
||||
activity?.AddTag("mcp.transport.session.id", beforeSessionId);
|
||||
}
|
||||
var tagValue = activity?.GetTagItem("mcp.transport.session.id")?.ToString();
|
||||
|
||||
var result = await next(context);
|
||||
|
||||
// After the handler, check response headers too (for test validation only).
|
||||
string? afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"];
|
||||
|
||||
capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method));
|
||||
capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var client = await ConnectAsync();
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// The filter must have observed at least one MCP request. Don't assert an exact
|
||||
// minimum — the initialized notification or GET stream may not have completed yet.
|
||||
Assert.NotEmpty(capturedSessionIds);
|
||||
|
||||
if (Stateless)
|
||||
{
|
||||
// Stateless mode: no session IDs anywhere.
|
||||
Assert.All(capturedSessionIds, c =>
|
||||
{
|
||||
Assert.Null(c.BeforeNext);
|
||||
Assert.Null(c.AfterNext);
|
||||
});
|
||||
|
||||
// Activity should exist but no transport session tag in stateless mode.
|
||||
Assert.All(capturedActivityTags, c => Assert.Null(c.TagValue));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stateful mode: response header is set on every POST and GET response.
|
||||
var postCaptures = capturedSessionIds.Where(c => c.Method is "POST").ToList();
|
||||
Assert.NotEmpty(postCaptures);
|
||||
|
||||
Assert.All(postCaptures, c =>
|
||||
{
|
||||
Assert.Equal(client.SessionId, c.AfterNext);
|
||||
});
|
||||
|
||||
// At least one POST should have the session ID in the request header too
|
||||
// (the initialized notification or list_tools — but not the initial initialize request).
|
||||
Assert.Contains(postCaptures, c => c.BeforeNext == client.SessionId);
|
||||
|
||||
// Verify Activity.Current was available and the AddTag pattern works before next().
|
||||
// The tag is only set on non-initialize requests (where the request header has the session ID).
|
||||
var taggedPosts = capturedActivityTags.Where(c => c.Method is "POST" && c.TagValue is not null).ToList();
|
||||
Assert.NotEmpty(taggedPosts);
|
||||
Assert.All(taggedPosts, c =>
|
||||
{
|
||||
Assert.True(c.HadActivity, "Activity.Current should be non-null in the endpoint filter");
|
||||
Assert.Equal(client.SessionId, c.TagValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
|||
protected abstract bool UseStreamableHttp { get; }
|
||||
protected abstract bool Stateless { get; }
|
||||
|
||||
protected void ConfigureStateless(HttpServerTransportOptions options)
|
||||
protected virtual void ConfigureStateless(HttpServerTransportOptions options)
|
||||
{
|
||||
options.Stateless = Stateless;
|
||||
}
|
||||
|
|
@ -206,7 +206,7 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
|||
[Fact]
|
||||
public async Task Server_ShutsDownQuickly_WhenClientIsConnected()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<ClaimsPrincipalTools>();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<ClaimsPrincipalTools>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
[Fact]
|
||||
public async Task ConnectAndReceiveMessage_InMemoryServer()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true);
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
|
@ -83,6 +83,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(httpTransportOptions =>
|
||||
{
|
||||
httpTransportOptions.EnableLegacySse = true;
|
||||
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental
|
||||
httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
|
|
@ -127,7 +128,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
{
|
||||
firstOptionsCallbackCallCount++;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithHttpTransport(options => options.EnableLegacySse = true)
|
||||
.WithTools<EchoTool>();
|
||||
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
|
|
@ -171,7 +172,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
public async Task AdditionalHeaders_AreSent_InGetAndPostRequests()
|
||||
{
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport();
|
||||
.WithHttpTransport(options => options.EnableLegacySse = true);
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
|
|
@ -218,7 +219,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
public async Task EmptyAdditionalHeadersKey_Throws_InvalidOperationException()
|
||||
{
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport();
|
||||
.WithHttpTransport(options => options.EnableLegacySse = true);
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
|
|
@ -310,7 +311,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
[Fact]
|
||||
public async Task Completion_ServerShutdown_ReturnsHttpCompletionDetails()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true);
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
|
|
|||
|
|
@ -189,6 +189,109 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
Assert.Equal("From request middleware!", Assert.IsType<TextContentBlock>(toolContent).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProgressNotifications_Work_InStatelessMode()
|
||||
{
|
||||
// Use TCS to coordinate: the tool reports progress, then waits for the test to confirm
|
||||
// the notification arrived before completing. This avoids the race where fire-and-forget
|
||||
// NotifyProgressAsync hasn't flushed before the SSE stream closes.
|
||||
var progressReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var toolCanComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
options.Stateless = true;
|
||||
})
|
||||
.WithTools([McpServerTool.Create(
|
||||
async (IProgress<ProgressNotificationValue> progress) =>
|
||||
{
|
||||
progress.Report(new() { Progress = 0, Total = 1, Message = "Working" });
|
||||
await toolCanComplete.Task;
|
||||
return "complete";
|
||||
}, new() { Name = "progressTool" })]);
|
||||
|
||||
_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"));
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// Use a custom IProgress<T> that sets the TCS synchronously (no thread pool posting).
|
||||
var callTask = client.CallToolAsync(
|
||||
"progressTool",
|
||||
progress: new SynchronousProgress<ProgressNotificationValue>(_ => progressReceived.TrySetResult()),
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for the progress notification to arrive at the client.
|
||||
await progressReceived.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
||||
|
||||
// Let the tool complete now that we've confirmed progress was received.
|
||||
toolCanComplete.SetResult();
|
||||
|
||||
var toolResponse = await callTask;
|
||||
var content = Assert.Single(toolResponse.Content);
|
||||
Assert.Equal("complete", Assert.IsType<TextContentBlock>(content).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSessionOptions_RunsPerRequest_InStatelessMode()
|
||||
{
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
options.Stateless = true;
|
||||
options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) =>
|
||||
{
|
||||
// Dynamically add a tool based on a request header value.
|
||||
var toolSuffix = httpContext.Request.Headers["X-Tool-Suffix"].ToString();
|
||||
if (!string.IsNullOrEmpty(toolSuffix))
|
||||
{
|
||||
mcpServerOptions.ToolCollection =
|
||||
[
|
||||
McpServerTool.Create(() => $"configured-{toolSuffix}", new() { Name = "dynamicTool" })
|
||||
];
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
|
||||
_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"));
|
||||
|
||||
// Two separate McpClient instances are needed because the X-Tool-Suffix header is set on
|
||||
// the shared HttpClient before connecting. Each McpClient captures the headers at connect
|
||||
// time, so changing headers between clients proves ConfigureSessionOptions sees different
|
||||
// request data on each HTTP request.
|
||||
|
||||
// First request with "alpha" — proves ConfigureSessionOptions runs and configures the tool.
|
||||
HttpClient.DefaultRequestHeaders.Add("X-Tool-Suffix", "alpha");
|
||||
|
||||
await using var client1 = await ConnectMcpClientAsync();
|
||||
|
||||
var toolResponse1 = await client1.CallToolAsync("dynamicTool", cancellationToken: TestContext.Current.CancellationToken);
|
||||
var content1 = Assert.Single(toolResponse1.Content);
|
||||
Assert.Equal("configured-alpha", Assert.IsType<TextContentBlock>(content1).Text);
|
||||
|
||||
// Second request with "beta" — proves ConfigureSessionOptions runs again with new request data.
|
||||
HttpClient.DefaultRequestHeaders.Remove("X-Tool-Suffix");
|
||||
HttpClient.DefaultRequestHeaders.Add("X-Tool-Suffix", "beta");
|
||||
|
||||
await using var client2 = await ConnectMcpClientAsync();
|
||||
|
||||
var toolResponse2 = await client2.CallToolAsync("dynamicTool", cancellationToken: TestContext.Current.CancellationToken);
|
||||
var content2 = Assert.Single(toolResponse2.Content);
|
||||
Assert.Equal("configured-beta", Assert.IsType<TextContentBlock>(content2).Text);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "testSamplingErrors")]
|
||||
public static async Task<string> TestSamplingErrors(McpServer server)
|
||||
{
|
||||
|
|
@ -253,4 +356,9 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
{
|
||||
public string? State { get; set; }
|
||||
}
|
||||
|
||||
private class SynchronousProgress<T>(Action<T> handler) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => handler(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,6 +241,29 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(ListToolsRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains("Mcp-Session-Id", body);
|
||||
Assert.Contains("Stateless", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWithoutSessionId_Returns400_WithStatelessGuidance()
|
||||
{
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
// Clear session ID and send GET without it.
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
HttpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
|
||||
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains("Mcp-Session-Id", body);
|
||||
Assert.Contains("Stateless", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Reference in a new issue