Add stateless Streamable HTTP support (#392)
- It allows a single MCP session spanning multiple requests to be handled by different servers without sharing state - It does require the servers share data protection keys, but this is standard for ASP.NET Core cookies and antiforgery as well - It immediately throws for unsupported operations like sampling
This commit is contained in:
parent
ca3a8ab921
commit
ce9f73f43f
31 changed files with 785 additions and 230 deletions
|
|
@ -26,6 +26,7 @@ public static class HttpMcpServerBuilderExtensions
|
|||
builder.Services.TryAddSingleton<StreamableHttpHandler>();
|
||||
builder.Services.TryAddSingleton<SseHandler>();
|
||||
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
|
||||
builder.Services.AddDataProtection();
|
||||
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.AspNetCore.Stateless;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore;
|
||||
|
||||
internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport transport, ClaimsPrincipal user, TimeProvider timeProvider) : IAsyncDisposable
|
||||
internal sealed class HttpMcpSession<TTransport>(
|
||||
string sessionId,
|
||||
TTransport transport,
|
||||
UserIdClaim? userId,
|
||||
TimeProvider timeProvider) : IAsyncDisposable
|
||||
where TTransport : ITransport
|
||||
{
|
||||
private int _referenceCount;
|
||||
|
|
@ -13,7 +18,7 @@ internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport tr
|
|||
|
||||
public string Id { get; } = sessionId;
|
||||
public TTransport Transport { get; } = transport;
|
||||
public (string Type, string Value, string Issuer)? UserIdClaim { get; } = GetUserIdClaim(user);
|
||||
public UserIdClaim? UserIdClaim { get; } = userId;
|
||||
|
||||
public CancellationToken SessionClosed => _disposeCts.Token;
|
||||
|
||||
|
|
@ -63,27 +68,7 @@ internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport tr
|
|||
}
|
||||
|
||||
public bool HasSameUserId(ClaimsPrincipal user)
|
||||
=> UserIdClaim == GetUserIdClaim(user);
|
||||
|
||||
// SignalR only checks for ClaimTypes.NameIdentifier in HttpConnectionDispatcher, but AspNetCore.Antiforgery checks that plus the sub and UPN claims.
|
||||
// However, we short-circuit unlike antiforgery since we expect to call this to verify MCP messages a lot more frequently than
|
||||
// verifying antiforgery tokens from <form> posts.
|
||||
private static (string Type, string Value, string Issuer)? GetUserIdClaim(ClaimsPrincipal user)
|
||||
{
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var claim = user.FindFirst(ClaimTypes.NameIdentifier) ?? user.FindFirst("sub") ?? user.FindFirst(ClaimTypes.Upn);
|
||||
|
||||
if (claim is { } idClaim)
|
||||
{
|
||||
return (idClaim.Type, idClaim.Value, idClaim.Issuer);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
=> UserIdClaim == StreamableHttpHandler.GetUserIdClaim(user);
|
||||
|
||||
private sealed class UnreferenceDisposable(HttpMcpSession<TTransport> session, TimeProvider timeProvider) : IDisposable
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,19 +23,37 @@ public class HttpServerTransportOptions
|
|||
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents the duration of time the server will wait between any active requests before timing out an
|
||||
/// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will
|
||||
/// receive a 404 status code and should restart their session. A client can keep their session open by
|
||||
/// keeping a GET request open. The default value is set to 2 hours.
|
||||
/// Gets or sets whether the server should run in a stateless mode that does not require all requests for a given session
|
||||
/// to arrive to the same ASP.NET Core application process.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If <see langword="true"/>, the "/sse" endpoint will be disabled, and client information will be round-tripped as part
|
||||
/// of the "mcp-session-id" header instead of stored in memory. Unsolicited server-to-client messages and all server-to-client
|
||||
/// requests are also unsupported, because any responses may arrive at another ASP.NET Core application process.
|
||||
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
|
||||
/// Defaults to <see langword="false"/>.
|
||||
/// </remarks>
|
||||
public bool Stateless { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the duration of time the server will wait between any active requests before timing out an MCP session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is checked in background every 5 seconds. A client trying to resume a session will receive a 404 status code
|
||||
/// and should restart their session. A client can keep their session open by keeping a GET request open.
|
||||
/// Defaults to 2 hours.
|
||||
/// </remarks>
|
||||
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of idle sessions to track. This is used to limit the number of sessions that can be idle at once.
|
||||
/// Gets or sets maximum number of idle sessions to track in memory. This is used to limit the number of sessions that can be idle at once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Past this limit, the server will log a critical error and terminate the oldest idle sessions even if they have not reached
|
||||
/// their <see cref="IdleTimeout"/> until the idle session count is below this limit. Clients that keep their session open by
|
||||
/// keeping a GET request open will not count towards this limit. The default value is set to 100,000 sessions.
|
||||
/// </summary>
|
||||
/// keeping a GET request open will not count towards this limit.
|
||||
/// Defaults to 100,000 sessions.
|
||||
/// </remarks>
|
||||
public int MaxIdleSessionCount { get; set; } = 100_000;
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ internal sealed partial class IdleTrackingBackgroundService(
|
|||
ILogger<IdleTrackingBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
// The compiler will complain about the parameter being unused otherwise despite the source generator.
|
||||
private ILogger _logger = logger;
|
||||
private readonly ILogger _logger = logger;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,20 +35,27 @@ public static class McpEndpointRouteBuilderExtensions
|
|||
.WithMetadata(new AcceptsMetadata(["application/json"]))
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]))
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
|
||||
streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync)
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
|
||||
streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync);
|
||||
|
||||
// Map legacy HTTP with SSE endpoints.
|
||||
var sseHandler = endpoints.ServiceProvider.GetRequiredService<SseHandler>();
|
||||
var sseGroup = mcpGroup.MapGroup("")
|
||||
.WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}");
|
||||
if (!streamableHttpHandler.HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
// The GET and DELETE endpoints are not mapped in Stateless mode since there's no way to send unsolicited messages
|
||||
// for the GET to handle, and there is no server-side state for the DELETE to clean up.
|
||||
streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync)
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
|
||||
streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync);
|
||||
|
||||
sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync)
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
|
||||
sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync)
|
||||
.WithMetadata(new AcceptsMetadata(["application/json"]))
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
|
||||
// Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot guarantee the /message requests
|
||||
// will be handled by the same process as the /sse request.
|
||||
var sseHandler = endpoints.ServiceProvider.GetRequiredService<SseHandler>();
|
||||
var sseGroup = mcpGroup.MapGroup("")
|
||||
.WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}");
|
||||
|
||||
sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync)
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
|
||||
sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync)
|
||||
.WithMetadata(new AcceptsMetadata(["application/json"]))
|
||||
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
|
||||
}
|
||||
|
||||
return mcpGroup;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ internal sealed class SseHandler(
|
|||
var requestPath = (context.Request.PathBase + context.Request.Path).ToString();
|
||||
var endpointPattern = requestPath[..(requestPath.LastIndexOf('/') + 1)];
|
||||
await using var transport = new SseResponseStreamTransport(context.Response.Body, $"{endpointPattern}message?sessionId={sessionId}");
|
||||
await using var httpMcpSession = new HttpMcpSession<SseResponseStreamTransport>(sessionId, transport, context.User, httpMcpServerOptions.Value.TimeProvider);
|
||||
|
||||
var userIdClaim = StreamableHttpHandler.GetUserIdClaim(context.User);
|
||||
await using var httpMcpSession = new HttpMcpSession<SseResponseStreamTransport>(sessionId, transport, userIdClaim, httpMcpServerOptions.Value.TimeProvider);
|
||||
|
||||
if (!_sessions.TryAdd(sessionId, httpMcpSession))
|
||||
{
|
||||
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
using ModelContextProtocol.Protocol.Types;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Stateless;
|
||||
|
||||
internal sealed class StatelessSessionId
|
||||
{
|
||||
[JsonPropertyName("clientInfo")]
|
||||
public Implementation? ClientInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("userIdClaim")]
|
||||
public UserIdClaim? UserIdClaim { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Stateless;
|
||||
|
||||
[JsonSerializable(typeof(StatelessSessionId))]
|
||||
internal sealed partial class StatelessSessionIdJsonContext : JsonSerializerContext;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
namespace ModelContextProtocol.AspNetCore.Stateless;
|
||||
|
||||
internal sealed record UserIdClaim(string Type, string Value, string Issuer);
|
||||
|
|
@ -1,17 +1,22 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using ModelContextProtocol.AspNetCore.Stateless;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipelines;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore;
|
||||
|
|
@ -19,16 +24,22 @@ namespace ModelContextProtocol.AspNetCore;
|
|||
internal sealed class StreamableHttpHandler(
|
||||
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
|
||||
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
|
||||
IOptions<HttpServerTransportOptions> httpMcpServerOptions,
|
||||
IOptions<HttpServerTransportOptions> httpServerTransportOptions,
|
||||
IDataProtectionProvider dataProtection,
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceProvider applicationServices)
|
||||
{
|
||||
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
|
||||
|
||||
private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json");
|
||||
private static readonly MediaTypeHeaderValue s_textEventStreamMediaType = new("text/event-stream");
|
||||
|
||||
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
|
||||
|
||||
private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId");
|
||||
|
||||
public async Task HandlePostRequestAsync(HttpContext context)
|
||||
{
|
||||
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
|
||||
|
|
@ -50,14 +61,28 @@ internal sealed class StreamableHttpHandler(
|
|||
return;
|
||||
}
|
||||
|
||||
using var _ = session.AcquireReference();
|
||||
InitializeSseResponse(context);
|
||||
var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), context.RequestAborted);
|
||||
if (!wroteResponse)
|
||||
try
|
||||
{
|
||||
// We wound up writing nothing, so there should be no Content-Type response header.
|
||||
context.Response.Headers.ContentType = (string?)null;
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
using var _ = session.AcquireReference();
|
||||
|
||||
InitializeSseResponse(context);
|
||||
var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), context.RequestAborted);
|
||||
if (!wroteResponse)
|
||||
{
|
||||
// We wound up writing nothing, so there should be no Content-Type response header.
|
||||
context.Response.Headers.ContentType = (string?)null;
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the mcp-session-id.
|
||||
// Non-stateless sessions are 1:1 with the mcp-session-id and outlive the POST request.
|
||||
// Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService.
|
||||
if (HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
await session.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,27 +133,39 @@ internal sealed class StreamableHttpHandler(
|
|||
|
||||
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetSessionAsync(HttpContext context, string sessionId)
|
||||
{
|
||||
if (Sessions.TryGetValue(sessionId, out var existingSession))
|
||||
{
|
||||
if (!existingSession.HasSameUserId(context.User))
|
||||
{
|
||||
await WriteJsonRpcErrorAsync(context,
|
||||
"Forbidden: The currently authenticated user does not match the user who initiated the session.",
|
||||
StatusCodes.Status403Forbidden);
|
||||
return null;
|
||||
}
|
||||
HttpMcpSession<StreamableHttpServerTransport>? session;
|
||||
|
||||
context.Response.Headers["mcp-session-id"] = existingSession.Id;
|
||||
context.Features.Set(existingSession.Server);
|
||||
return existingSession;
|
||||
if (HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
var sessionJson = Protector.Unprotect(sessionId);
|
||||
var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId);
|
||||
var transport = new StreamableHttpServerTransport
|
||||
{
|
||||
Stateless = true,
|
||||
};
|
||||
session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId);
|
||||
}
|
||||
else if (!Sessions.TryGetValue(sessionId, out session))
|
||||
{
|
||||
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
|
||||
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
|
||||
// JSON-RPC library from Microsoft called StreamJsonRpc where it's called JsonRpcErrorCode.NoMarshaledObjectFound
|
||||
// https://learn.microsoft.com/dotnet/api/streamjsonrpc.protocol.jsonrpcerrorcode?view=streamjsonrpc-2.9#fields
|
||||
await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001);
|
||||
return null;
|
||||
}
|
||||
|
||||
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
|
||||
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
|
||||
// JSON-RPC library from Microsoft called StreamJsonRpc where it's called JsonRpcErrorCode.NoMarshaledObjectFound
|
||||
// https://learn.microsoft.com/dotnet/api/streamjsonrpc.protocol.jsonrpcerrorcode?view=streamjsonrpc-2.9#fields
|
||||
await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, 32001);
|
||||
return null;
|
||||
if (!session.HasSameUserId(context.User))
|
||||
{
|
||||
await WriteJsonRpcErrorAsync(context,
|
||||
"Forbidden: The currently authenticated user does not match the user who initiated the session.",
|
||||
StatusCodes.Status403Forbidden);
|
||||
return null;
|
||||
}
|
||||
|
||||
context.Response.Headers["mcp-session-id"] = session.Id;
|
||||
context.Features.Set(session.Server);
|
||||
return session;
|
||||
}
|
||||
|
||||
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetOrCreateSessionAsync(HttpContext context)
|
||||
|
|
@ -137,14 +174,7 @@ internal sealed class StreamableHttpHandler(
|
|||
|
||||
if (string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
var session = await CreateSessionAsync(context);
|
||||
|
||||
if (!Sessions.TryAdd(session.Id, session))
|
||||
{
|
||||
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
|
||||
}
|
||||
|
||||
return session;
|
||||
return await StartNewSessionAsync(context);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -152,29 +182,79 @@ internal sealed class StreamableHttpHandler(
|
|||
}
|
||||
}
|
||||
|
||||
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSessionAsync(HttpContext context)
|
||||
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewSessionAsync(HttpContext context)
|
||||
{
|
||||
var sessionId = MakeNewSessionId();
|
||||
context.Response.Headers["mcp-session-id"] = sessionId;
|
||||
string sessionId;
|
||||
StreamableHttpServerTransport transport;
|
||||
|
||||
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
|
||||
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
|
||||
if (!HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
|
||||
await configureSessionOptions(context, mcpServerOptions, context.RequestAborted);
|
||||
sessionId = MakeNewSessionId();
|
||||
transport = new();
|
||||
context.Response.Headers["mcp-session-id"] = sessionId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// "(uninitialized stateless id)" is not written anywhere. We delay writing the mcp-session-id
|
||||
// until after we receive the initialize request with the client info we need to serialize.
|
||||
sessionId = "(uninitialized stateless id)";
|
||||
transport = new()
|
||||
{
|
||||
Stateless = true,
|
||||
};
|
||||
ScheduleStatelessSessionIdWrite(context, transport);
|
||||
}
|
||||
|
||||
var transport = new StreamableHttpServerTransport();
|
||||
// Use application instead of request services, because the session will likely outlive the first initialization request.
|
||||
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, applicationServices);
|
||||
var session = await CreateSessionAsync(context, transport, sessionId);
|
||||
|
||||
// The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the mcp-session-id.
|
||||
if (!HttpServerTransportOptions.Stateless)
|
||||
{
|
||||
if (!Sessions.TryAdd(sessionId, session))
|
||||
{
|
||||
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSessionAsync(
|
||||
HttpContext context,
|
||||
StreamableHttpServerTransport transport,
|
||||
string sessionId,
|
||||
StatelessSessionId? statelessId = null)
|
||||
{
|
||||
var mcpServerServices = applicationServices;
|
||||
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
|
||||
if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null)
|
||||
{
|
||||
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
|
||||
|
||||
if (statelessId is not null)
|
||||
{
|
||||
// The session does not outlive the request in stateless mode.
|
||||
mcpServerServices = context.RequestServices;
|
||||
mcpServerOptions.ScopeRequests = false;
|
||||
mcpServerOptions.KnownClientInfo = statelessId.ClientInfo;
|
||||
}
|
||||
|
||||
if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions)
|
||||
{
|
||||
await configureSessionOptions(context, mcpServerOptions, context.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
|
||||
context.Features.Set(server);
|
||||
|
||||
var session = new HttpMcpSession<StreamableHttpServerTransport>(sessionId, transport, context.User, httpMcpServerOptions.Value.TimeProvider)
|
||||
var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User);
|
||||
var session = new HttpMcpSession<StreamableHttpServerTransport>(sessionId, transport, userIdClaim, HttpServerTransportOptions.TimeProvider)
|
||||
{
|
||||
Server = server,
|
||||
};
|
||||
|
||||
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? RunSessionAsync;
|
||||
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
|
||||
session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed);
|
||||
|
||||
return session;
|
||||
|
|
@ -210,9 +290,48 @@ internal sealed class StreamableHttpHandler(
|
|||
return WebEncoders.Base64UrlEncode(buffer);
|
||||
}
|
||||
|
||||
private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport)
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var statelessId = new StatelessSessionId
|
||||
{
|
||||
ClientInfo = transport?.InitializeRequest?.ClientInfo,
|
||||
UserIdClaim = GetUserIdClaim(context.User),
|
||||
};
|
||||
|
||||
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
|
||||
var sessionId = Protector.Protect(sessionJson);
|
||||
|
||||
context.Response.Headers["mcp-session-id"] = sessionId;
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
|
||||
=> session.RunAsync(requestAborted);
|
||||
|
||||
// SignalR only checks for ClaimTypes.NameIdentifier in HttpConnectionDispatcher, but AspNetCore.Antiforgery checks that plus the sub and UPN claims.
|
||||
// However, we short-circuit unlike antiforgery since we expect to call this to verify MCP messages a lot more frequently than
|
||||
// verifying antiforgery tokens from <form> posts.
|
||||
internal static UserIdClaim? GetUserIdClaim(ClaimsPrincipal user)
|
||||
{
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var claim = user.FindFirst(ClaimTypes.NameIdentifier) ?? user.FindFirst("sub") ?? user.FindFirst(ClaimTypes.Upn);
|
||||
|
||||
if (claim is { } idClaim)
|
||||
{
|
||||
return new(idClaim.Type, idClaim.Value, idClaim.Issuer);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
|
||||
|
||||
private sealed class HttpDuplexPipe(HttpContext context) : IDuplexPipe
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ public class JsonRpcRequest : JsonRpcMessageWithId
|
|||
JsonRpc = JsonRpc,
|
||||
Id = id,
|
||||
Method = Method,
|
||||
Params = Params
|
||||
Params = Params,
|
||||
RelatedTransport = RelatedTransport,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
cancellationToken = sendCts.Token;
|
||||
|
||||
#if NET
|
||||
using var content = JsonContent.Create(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcMessage>());
|
||||
using var content = JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
|
||||
#else
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Utils;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net.ServerSentEvents;
|
||||
|
|
@ -13,12 +12,11 @@ namespace ModelContextProtocol.Protocol.Transport;
|
|||
/// Handles processing the request/response body pairs for the Streamable HTTP transport.
|
||||
/// This is typically used via <see cref="JsonRpcMessage.RelatedTransport"/>.
|
||||
/// </summary>
|
||||
internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>? incomingChannel, IDuplexPipe httpBodies) : ITransport
|
||||
internal sealed class StreamableHttpPostTransport(StreamableHttpServerTransport parentTransport, IDuplexPipe httpBodies) : ITransport
|
||||
{
|
||||
private readonly SseWriter _sseWriter = new();
|
||||
private readonly HashSet<RequestId> _pendingRequests = [];
|
||||
private RequestId _pendingRequest;
|
||||
|
||||
// REVIEW: Should we introduce a send-only interface for RelatedTransport?
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => throw new NotSupportedException("JsonRpcMessage.RelatedTransport should only be used for sending messages.");
|
||||
|
||||
/// <returns>
|
||||
|
|
@ -28,15 +26,11 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
|
|||
/// </returns>
|
||||
public async ValueTask<bool> RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The incomingChannel is null to handle the potential client GET request to handle unsolicited JsonRpcMessages.
|
||||
if (incomingChannel is not null)
|
||||
{
|
||||
var message = await JsonSerializer.DeserializeAsync(httpBodies.Input.AsStream(),
|
||||
McpJsonUtilities.JsonContext.Default.JsonRpcMessage, cancellationToken).ConfigureAwait(false);
|
||||
await OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
var message = await JsonSerializer.DeserializeAsync(httpBodies.Input.AsStream(),
|
||||
McpJsonUtilities.JsonContext.Default.JsonRpcMessage, cancellationToken).ConfigureAwait(false);
|
||||
await OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_pendingRequests.Count == 0)
|
||||
if (_pendingRequest.Id is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -48,6 +42,11 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
|
|||
|
||||
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (parentTransport.Stateless && message is JsonRpcRequest)
|
||||
{
|
||||
throw new InvalidOperationException("Server to client requests are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -62,13 +61,10 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
|
|||
{
|
||||
yield return message;
|
||||
|
||||
if (message.Data is JsonRpcMessageWithId response)
|
||||
if (message.Data is JsonRpcMessageWithId response && response.Id == _pendingRequest)
|
||||
{
|
||||
if (_pendingRequests.Remove(response.Id) && _pendingRequests.Count == 0)
|
||||
{
|
||||
// Complete the SSE response stream now that all pending requests have been processed.
|
||||
break;
|
||||
}
|
||||
// Complete the SSE response stream now that all pending requests have been processed.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -82,13 +78,17 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
|
|||
|
||||
if (message is JsonRpcRequest request)
|
||||
{
|
||||
_pendingRequests.Add(request.Id);
|
||||
_pendingRequest = request.Id;
|
||||
|
||||
// Store client capabilities so they can be serialized by "stateless" callers for use in later requests.
|
||||
if (parentTransport.Stateless && request.Method == RequestMethods.Initialize)
|
||||
{
|
||||
parentTransport.InitializeRequest = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.JsonContext.Default.InitializeRequestParams);
|
||||
}
|
||||
}
|
||||
|
||||
message.RelatedTransport = this;
|
||||
|
||||
// Really an assertion. This doesn't get called when incomingChannel is null for GET requests.
|
||||
Throw.IfNull(incomingChannel);
|
||||
await incomingChannel.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using System.IO.Pipelines;
|
||||
using System.Threading.Channels;
|
||||
|
||||
|
|
@ -36,6 +37,25 @@ public sealed class StreamableHttpServerTransport : ITransport
|
|||
|
||||
private int _getRequestStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Configures whether the transport should be in stateless mode that does not require all requests for a given session
|
||||
/// to arrive to the same ASP.NET Core application process. Unsolicited server-to-client messages are not supported in this mode,
|
||||
/// so calling <see cref="HandleGetRequest(Stream, CancellationToken)"/> results in an <see cref="InvalidOperationException"/>.
|
||||
/// Server-to-client requests are also unsupported, because the responses may arrive at another ASP.NET Core application process.
|
||||
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
|
||||
/// </summary>
|
||||
public bool Stateless { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the initialize request if it was received by <see cref="HandlePostRequest(IDuplexPipe, CancellationToken)"/> and <see cref="Stateless"/> is set to <see langword="true"/>.
|
||||
/// </summary>
|
||||
public InitializeRequestParams? InitializeRequest { get; internal set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => _incomingChannel.Reader;
|
||||
|
||||
internal ChannelWriter<JsonRpcMessage> MessageWriter => _incomingChannel.Writer;
|
||||
|
||||
/// <summary>
|
||||
/// Handles an optional SSE GET request a client using the Streamable HTTP transport might make by
|
||||
/// writing any unsolicited JSON-RPC messages sent via <see cref="SendMessageAsync"/>
|
||||
|
|
@ -46,6 +66,11 @@ public sealed class StreamableHttpServerTransport : ITransport
|
|||
/// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns>
|
||||
public async Task HandleGetRequest(Stream sseResponseStream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Stateless)
|
||||
{
|
||||
throw new InvalidOperationException("GET requests are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _getRequestStarted, 1) == 1)
|
||||
{
|
||||
throw new InvalidOperationException("Session resumption is not yet supported. Please start a new session.");
|
||||
|
|
@ -63,37 +88,45 @@ public sealed class StreamableHttpServerTransport : ITransport
|
|||
/// <param name="httpBodies">The duplex pipe facilitates the reading and writing of HTTP request and response data.</param>
|
||||
/// <param name="cancellationToken">This token allows for the operation to be canceled if needed.</param>
|
||||
/// <returns>
|
||||
/// True, if data was written to the respond body.
|
||||
/// True, if data was written to the response body.
|
||||
/// False, if nothing was written because the request body did not contain any <see cref="JsonRpcRequest"/> messages to respond to.
|
||||
/// The HTTP application should typically respond with an empty "202 Accepted" response in this scenario.
|
||||
/// </returns>
|
||||
public async Task<bool> HandlePostRequest(IDuplexPipe httpBodies, CancellationToken cancellationToken)
|
||||
{
|
||||
using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, cancellationToken);
|
||||
await using var postTransport = new StreamableHttpPostTransport(_incomingChannel.Writer, httpBodies);
|
||||
await using var postTransport = new StreamableHttpPostTransport(this, httpBodies);
|
||||
return await postTransport.RunAsync(postCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => _incomingChannel.Reader;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Stateless)
|
||||
{
|
||||
throw new InvalidOperationException("Unsolicited server to client messages are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_disposeCts.Cancel();
|
||||
try
|
||||
{
|
||||
await _sseWriter.DisposeAsync().ConfigureAwait(false);
|
||||
await _disposeCts.CancelAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_disposeCts.Dispose();
|
||||
try
|
||||
{
|
||||
await _sseWriter.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_disposeCts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
|
|||
private readonly bool _servicesScopePerRequest;
|
||||
private readonly List<Action> _disposables = [];
|
||||
|
||||
private string _endpointName;
|
||||
private readonly string _serverOnlyEndpointName;
|
||||
private string? _endpointName;
|
||||
private int _started;
|
||||
|
||||
/// <summary>Holds a boxed <see cref="LoggingLevel"/> value for the server.</summary>
|
||||
|
|
@ -54,9 +55,12 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
|
|||
_sessionTransport = transport;
|
||||
ServerOptions = options;
|
||||
Services = serviceProvider;
|
||||
_endpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})";
|
||||
_serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})";
|
||||
_servicesScopePerRequest = options.ScopeRequests;
|
||||
|
||||
ClientInfo = options.KnownClientInfo;
|
||||
UpdateEndpointNameWithClientInfo();
|
||||
|
||||
// Configure all request handlers based on the supplied options.
|
||||
ServerCapabilities = new();
|
||||
ConfigureInitialize(options);
|
||||
|
|
@ -75,26 +79,29 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
|
|||
}
|
||||
|
||||
// Now that everything has been configured, subscribe to any necessary notifications.
|
||||
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
|
||||
if (transport is not StreamableHttpServerTransport streamableHttpTransport || streamableHttpTransport.Stateless is false)
|
||||
{
|
||||
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification);
|
||||
tools.Changed += changed;
|
||||
_disposables.Add(() => tools.Changed -= changed);
|
||||
}
|
||||
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
|
||||
{
|
||||
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification);
|
||||
tools.Changed += changed;
|
||||
_disposables.Add(() => tools.Changed -= changed);
|
||||
}
|
||||
|
||||
if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts)
|
||||
{
|
||||
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
|
||||
prompts.Changed += changed;
|
||||
_disposables.Add(() => prompts.Changed -= changed);
|
||||
}
|
||||
if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts)
|
||||
{
|
||||
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
|
||||
prompts.Changed += changed;
|
||||
_disposables.Add(() => prompts.Changed -= changed);
|
||||
}
|
||||
|
||||
var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection;
|
||||
if (resources is not null)
|
||||
{
|
||||
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
|
||||
resources.Changed += changed;
|
||||
_disposables.Add(() => resources.Changed -= changed);
|
||||
var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection;
|
||||
if (resources is not null)
|
||||
{
|
||||
EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
|
||||
resources.Changed += changed;
|
||||
_disposables.Add(() => resources.Changed -= changed);
|
||||
}
|
||||
}
|
||||
|
||||
// And initialize the session.
|
||||
|
|
@ -116,7 +123,7 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
|
|||
public IServiceProvider? Services { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string EndpointName => _endpointName;
|
||||
public override string EndpointName => _endpointName ?? _serverOnlyEndpointName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public LoggingLevel? LoggingLevel => _loggingLevel?.Value;
|
||||
|
|
@ -163,8 +170,8 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
|
|||
ClientInfo = request?.ClientInfo;
|
||||
|
||||
// Use the ClientInfo to update the session EndpointName for logging.
|
||||
_endpointName = $"{_endpointName}, Client ({ClientInfo?.Name} {ClientInfo?.Version})";
|
||||
GetSessionOrThrow().EndpointName = _endpointName;
|
||||
UpdateEndpointNameWithClientInfo();
|
||||
GetSessionOrThrow().EndpointName = EndpointName;
|
||||
|
||||
return new InitializeResult
|
||||
{
|
||||
|
|
@ -544,6 +551,16 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
|
|||
requestTypeInfo, responseTypeInfo);
|
||||
}
|
||||
|
||||
private void UpdateEndpointNameWithClientInfo()
|
||||
{
|
||||
if (ClientInfo is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_endpointName = $"{_serverOnlyEndpointName}, Client ({ClientInfo.Name} {ClientInfo.Version})";
|
||||
}
|
||||
|
||||
/// <summary>Maps a <see cref="LogLevel"/> to a <see cref="LoggingLevel"/>.</summary>
|
||||
internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
|
||||
level switch
|
||||
|
|
|
|||
|
|
@ -30,14 +30,10 @@ public static class McpServerExtensions
|
|||
/// and token limits.
|
||||
/// </remarks>
|
||||
public static ValueTask<CreateMessageResult> RequestSamplingAsync(
|
||||
this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken)
|
||||
this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(server);
|
||||
|
||||
if (server.ClientCapabilities?.Sampling is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support sampling.");
|
||||
}
|
||||
ThrowIfSamplingUnsupported(server);
|
||||
|
||||
return server.SendRequestAsync(
|
||||
RequestMethods.SamplingCreateMessage,
|
||||
|
|
@ -163,11 +159,7 @@ public static class McpServerExtensions
|
|||
public static IChatClient AsSamplingChatClient(this IMcpServer server)
|
||||
{
|
||||
Throw.IfNull(server);
|
||||
|
||||
if (server.ClientCapabilities?.Sampling is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support sampling.");
|
||||
}
|
||||
ThrowIfSamplingUnsupported(server);
|
||||
|
||||
return new SamplingChatClient(server);
|
||||
}
|
||||
|
|
@ -198,14 +190,10 @@ public static class McpServerExtensions
|
|||
/// or other structured data sources that the client makes available through the protocol.
|
||||
/// </remarks>
|
||||
public static ValueTask<ListRootsResult> RequestRootsAsync(
|
||||
this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken)
|
||||
this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(server);
|
||||
|
||||
if (server.ClientCapabilities?.Roots is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support roots.");
|
||||
}
|
||||
ThrowIfRootsUnsupported(server);
|
||||
|
||||
return server.SendRequestAsync(
|
||||
RequestMethods.RootsList,
|
||||
|
|
@ -215,6 +203,32 @@ public static class McpServerExtensions
|
|||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static void ThrowIfSamplingUnsupported(IMcpServer server)
|
||||
{
|
||||
if (server.ClientCapabilities?.Sampling is null)
|
||||
{
|
||||
if (server.ServerOptions.KnownClientInfo is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support sampling.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfRootsUnsupported(IMcpServer server)
|
||||
{
|
||||
if (server.ClientCapabilities?.Roots is null)
|
||||
{
|
||||
if (server.ServerOptions.KnownClientInfo is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Roots are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support roots.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Provides an <see cref="IChatClient"/> implementation that's implemented via client sampling.</summary>
|
||||
private sealed class SamplingChatClient(IMcpServer server) : IChatClient
|
||||
{
|
||||
|
|
|
|||
|
|
@ -65,4 +65,15 @@ public class McpServerOptions
|
|||
/// handler will be invoked within a new service scope.
|
||||
/// </remarks>
|
||||
public bool ScopeRequests { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets preexisting knowledge about the client including its name and version to help support
|
||||
/// stateless Streamable HTTP servers that encode this knowledge in the mcp-session-id header.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When not specified, this information is sourced from the client's initialize request.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Implementation? KnownClientInfo { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,6 +206,8 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
|
|||
[Fact]
|
||||
public async Task Sampling_Sse_TestServer()
|
||||
{
|
||||
Assert.SkipWhen(GetType() == typeof(StatelessServerIntegrationTests), "Sampling is not supported in stateless mode.");
|
||||
|
||||
// arrange
|
||||
// Set up the sampling handler
|
||||
int samplingHandlerCalls = 0;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,31 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper)
|
||||
{
|
||||
protected override bool UseStreamableHttp => false;
|
||||
protected override bool Stateless => false;
|
||||
|
||||
[Theory]
|
||||
[InlineData("/mcp")]
|
||||
[InlineData("/mcp/secondary")]
|
||||
public async Task Allows_Customizing_Route(string pattern)
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless);
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(pattern);
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var response = await HttpClient.GetAsync($"http://localhost{pattern}/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var sseStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
|
||||
using var sseStreamReader = new StreamReader(sseStream, System.Text.Encoding.UTF8);
|
||||
var eventLine = await sseStreamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
var dataLine = await sseStreamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(eventLine);
|
||||
Assert.Equal("event: endpoint", eventLine);
|
||||
Assert.NotNull(dataLine);
|
||||
Assert.Equal($"data: {pattern}/message", dataLine[..dataLine.IndexOf('?')]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a", "/a/sse")]
|
||||
|
|
@ -32,37 +57,4 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
|
|||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStreamableHttpTests(outputHelper)
|
||||
{
|
||||
protected override bool UseStreamableHttp => true;
|
||||
protected override bool Stateless => true;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue