Add server-side Streamable HTTP transport support (#330)
- Changes IJsonRpcMessage to an abstract base class so RelatedTransport will always be available - Streamable HTTP supports multiple concurrent HTTP request with their own indpendent SSE response streams - RelatedTransport indicates the source or destination of the JsonRpcMessage - Changes the default RequestId to a JSON number for better compatibility with MCP servers in the wild
This commit is contained in:
parent
7d77be68cf
commit
7dcba357d4
45 changed files with 1637 additions and 395 deletions
|
|
@ -11,11 +11,11 @@ public static class HttpMcpServerBuilderExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds the services necessary for <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/>
|
/// Adds the services necessary for <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/>
|
||||||
/// to handle MCP requests and sessions using the MCP HTTP Streaming transport. For more information on configuring the underlying HTTP server
|
/// to handle MCP requests and sessions using the MCP Streamable HTTP transport. For more information on configuring the underlying HTTP server
|
||||||
/// to control things like port binding custom TLS certificates, see the <see href="https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis">Minimal APIs quick reference</see>.
|
/// to control things like port binding custom TLS certificates, see the <see href="https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis">Minimal APIs quick reference</see>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="builder">The builder instance.</param>
|
/// <param name="builder">The builder instance.</param>
|
||||||
/// <param name="configureOptions">Configures options for the HTTP Streaming transport. This allows configuring per-session
|
/// <param name="configureOptions">Configures options for the Streamable HTTP transport. This allows configuring per-session
|
||||||
/// <see cref="McpServerOptions"/> and running logic before and after a session.</param>
|
/// <see cref="McpServerOptions"/> and running logic before and after a session.</param>
|
||||||
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
|
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
|
||||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
||||||
|
|
@ -23,6 +23,8 @@ public static class HttpMcpServerBuilderExtensions
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(builder);
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
builder.Services.TryAddSingleton<StreamableHttpHandler>();
|
builder.Services.TryAddSingleton<StreamableHttpHandler>();
|
||||||
|
builder.Services.TryAddSingleton<SseHandler>();
|
||||||
|
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
|
||||||
|
|
||||||
if (configureOptions is not null)
|
if (configureOptions is not null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,61 @@
|
||||||
using ModelContextProtocol.Protocol.Transport;
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore;
|
namespace ModelContextProtocol.AspNetCore;
|
||||||
|
|
||||||
internal class HttpMcpSession
|
internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport transport, ClaimsPrincipal user, TimeProvider timeProvider) : IAsyncDisposable
|
||||||
|
where TTransport : ITransport
|
||||||
{
|
{
|
||||||
public HttpMcpSession(SseResponseStreamTransport transport, ClaimsPrincipal user)
|
private int _referenceCount;
|
||||||
|
private int _getRequestStarted;
|
||||||
|
private CancellationTokenSource _disposeCts = new();
|
||||||
|
|
||||||
|
public string Id { get; } = sessionId;
|
||||||
|
public TTransport Transport { get; } = transport;
|
||||||
|
public (string Type, string Value, string Issuer)? UserIdClaim { get; } = GetUserIdClaim(user);
|
||||||
|
|
||||||
|
public CancellationToken SessionClosed => _disposeCts.Token;
|
||||||
|
|
||||||
|
public bool IsActive => !SessionClosed.IsCancellationRequested && _referenceCount > 0;
|
||||||
|
public long LastActivityTicks { get; private set; } = timeProvider.GetTimestamp();
|
||||||
|
|
||||||
|
public IMcpServer? Server { get; set; }
|
||||||
|
public Task? ServerRunTask { get; set; }
|
||||||
|
|
||||||
|
public IDisposable AcquireReference()
|
||||||
{
|
{
|
||||||
Transport = transport;
|
Interlocked.Increment(ref _referenceCount);
|
||||||
UserIdClaim = GetUserIdClaim(user);
|
return new UnreferenceDisposable(this, timeProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SseResponseStreamTransport Transport { get; }
|
public bool TryStartGetRequest() => Interlocked.Exchange(ref _getRequestStarted, 1) == 0;
|
||||||
public (string Type, string Value, string Issuer)? UserIdClaim { get; }
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _disposeCts.CancelAsync();
|
||||||
|
|
||||||
|
if (ServerRunTask is not null)
|
||||||
|
{
|
||||||
|
await ServerRunTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Server is not null)
|
||||||
|
{
|
||||||
|
await Server.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Transport.DisposeAsync();
|
||||||
|
_disposeCts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool HasSameUserId(ClaimsPrincipal user)
|
public bool HasSameUserId(ClaimsPrincipal user)
|
||||||
=> UserIdClaim == GetUserIdClaim(user);
|
=> UserIdClaim == GetUserIdClaim(user);
|
||||||
|
|
@ -36,4 +79,15 @@ internal class HttpMcpSession
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class UnreferenceDisposable(HttpMcpSession<TTransport> session, TimeProvider timeProvider) : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Decrement(ref session._referenceCount) == 0)
|
||||||
|
{
|
||||||
|
session.LastActivityTicks = timeProvider.GetTimestamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,17 @@ public class HttpServerTransportOptions
|
||||||
/// This is useful for running logic before a sessions starts and after it completes.
|
/// This is useful for running logic before a sessions starts and after it completes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
|
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 minutes.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used for testing the <see cref="IdleTimeout"/>.
|
||||||
|
/// </summary>
|
||||||
|
public TimeProvider TimeProvider { get; set; } = TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.AspNetCore;
|
||||||
|
|
||||||
|
internal sealed partial class IdleTrackingBackgroundService(
|
||||||
|
StreamableHttpHandler handler,
|
||||||
|
IOptions<HttpServerTransportOptions> options,
|
||||||
|
ILogger<IdleTrackingBackgroundService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
// The compiler will complain about the parameter being unused otherwise despite the source generator.
|
||||||
|
private ILogger _logger = logger;
|
||||||
|
|
||||||
|
// We can make this configurable once we properly harden the MCP server. In the meantime, anyone running
|
||||||
|
// this should be taking a cattle not pets approach to their servers and be able to launch more processes
|
||||||
|
// to handle more than 10,000 idle sessions at a time.
|
||||||
|
private const int MaxIdleSessionCount = 10_000;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var timeProvider = options.Value.TimeProvider;
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
var idleActivityCutoff = timeProvider.GetTimestamp() - options.Value.IdleTimeout.Ticks;
|
||||||
|
|
||||||
|
var idleCount = 0;
|
||||||
|
foreach (var (_, session) in handler.Sessions)
|
||||||
|
{
|
||||||
|
if (session.IsActive || session.SessionClosed.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// There's a request currently active or the session is already being closed.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
idleCount++;
|
||||||
|
if (idleCount == MaxIdleSessionCount)
|
||||||
|
{
|
||||||
|
// Emit critical log at most once every 5 seconds the idle count it exceeded,
|
||||||
|
//since the IdleTimeout will no longer be respected.
|
||||||
|
LogMaxSessionIdleCountExceeded();
|
||||||
|
}
|
||||||
|
else if (idleCount < MaxIdleSessionCount && session.LastActivityTicks > idleActivityCutoff)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handler.Sessions.TryRemove(session.Id, out var removedSession))
|
||||||
|
{
|
||||||
|
LogSessionIdle(removedSession.Id);
|
||||||
|
|
||||||
|
// Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
|
||||||
|
_ = DisposeSessionAsync(removedSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
List<Task> disposeSessionTasks = [];
|
||||||
|
|
||||||
|
foreach (var (sessionKey, _) in handler.Sessions)
|
||||||
|
{
|
||||||
|
if (handler.Sessions.TryRemove(sessionKey, out var session))
|
||||||
|
{
|
||||||
|
disposeSessionTasks.Add(DisposeSessionAsync(session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(disposeSessionTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransport> session)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogSessionDisposeError(session.Id, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Information, Message = "Closing idle session {sessionId}.")]
|
||||||
|
private partial void LogSessionIdle(string sessionId);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded static maximum of 10,000 idle connections. Now clearing all inactive connections regardless of timeout.")]
|
||||||
|
private partial void LogMaxSessionIdleCountExceeded();
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Error, Message = "Error disposing the IMcpServer for session {sessionId}.")]
|
||||||
|
private partial void LogSessionDisposeError(string sessionId, Exception ex);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Metadata;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ModelContextProtocol.AspNetCore;
|
using ModelContextProtocol.AspNetCore;
|
||||||
|
using ModelContextProtocol.Protocol.Messages;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.Builder;
|
namespace Microsoft.AspNetCore.Builder;
|
||||||
|
|
@ -11,21 +14,42 @@ namespace Microsoft.AspNetCore.Builder;
|
||||||
public static class McpEndpointRouteBuilderExtensions
|
public static class McpEndpointRouteBuilderExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets up endpoints for handling MCP HTTP Streaming transport.
|
/// Sets up endpoints for handling MCP Streamable HTTP transport.
|
||||||
/// See <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">the protocol specification</see> for details about the Streamable HTTP transport.
|
/// See <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">the 2025-03-26 protocol specification</see> for details about the Streamable HTTP transport.
|
||||||
|
/// Also maps legacy SSE endpoints for backward compatibility at the path "/sse" and "/message". <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">the 2024-11-05 protocol specification</see> for details about the HTTP with SSE transport.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
|
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
|
||||||
/// <param name="pattern">The route pattern prefix to map to.</param>
|
/// <param name="pattern">The route pattern prefix to map to.</param>
|
||||||
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
|
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
|
||||||
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
|
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
|
||||||
{
|
{
|
||||||
var handler = endpoints.ServiceProvider.GetService<StreamableHttpHandler>() ??
|
var streamableHttpHandler = endpoints.ServiceProvider.GetService<StreamableHttpHandler>() ??
|
||||||
throw new InvalidOperationException("You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code.");
|
throw new InvalidOperationException("You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code.");
|
||||||
|
|
||||||
var routeGroup = endpoints.MapGroup(pattern);
|
var mcpGroup = endpoints.MapGroup(pattern);
|
||||||
routeGroup.MapGet("", handler.HandleRequestAsync);
|
var streamableHttpGroup = mcpGroup.MapGroup("")
|
||||||
routeGroup.MapGet("/sse", handler.HandleRequestAsync);
|
.WithDisplayName(b => $"MCP Streamable HTTP | {b.DisplayName}")
|
||||||
routeGroup.MapPost("/message", handler.HandleRequestAsync);
|
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status404NotFound, typeof(JsonRpcError), contentTypes: ["application/json"]));
|
||||||
return routeGroup;
|
|
||||||
|
streamableHttpGroup.MapPost("", streamableHttpHandler.HandlePostRequestAsync)
|
||||||
|
.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}");
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
src/ModelContextProtocol.AspNetCore/SseHandler.cs
Normal file
110
src/ModelContextProtocol.AspNetCore/SseHandler.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ModelContextProtocol.Protocol.Messages;
|
||||||
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ModelContextProtocol.Utils.Json;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.AspNetCore;
|
||||||
|
|
||||||
|
internal sealed class SseHandler(
|
||||||
|
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
|
||||||
|
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
|
||||||
|
IOptions<HttpServerTransportOptions> httpMcpServerOptions,
|
||||||
|
IHostApplicationLifetime hostApplicationLifetime,
|
||||||
|
ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, HttpMcpSession<SseResponseStreamTransport>> _sessions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public async Task HandleSseRequestAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var sessionId = StreamableHttpHandler.MakeNewSessionId();
|
||||||
|
|
||||||
|
// If the server is shutting down, we need to cancel all SSE connections immediately without waiting for HostOptions.ShutdownTimeout
|
||||||
|
// which defaults to 30 seconds.
|
||||||
|
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
|
||||||
|
var cancellationToken = sseCts.Token;
|
||||||
|
|
||||||
|
StreamableHttpHandler.InitializeSseResponse(context);
|
||||||
|
|
||||||
|
await using var transport = new SseResponseStreamTransport(context.Response.Body, $"message?sessionId={sessionId}");
|
||||||
|
await using var httpMcpSession = new HttpMcpSession<SseResponseStreamTransport>(sessionId, transport, context.User, httpMcpServerOptions.Value.TimeProvider);
|
||||||
|
if (!_sessions.TryAdd(sessionId, httpMcpSession))
|
||||||
|
{
|
||||||
|
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
|
||||||
|
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
|
||||||
|
{
|
||||||
|
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
|
||||||
|
await configureSessionOptions(context, mcpServerOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var transportTask = transport.RunAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var mcpServer = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, context.RequestServices);
|
||||||
|
httpMcpSession.Server = mcpServer;
|
||||||
|
context.Features.Set(mcpServer);
|
||||||
|
|
||||||
|
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? StreamableHttpHandler.RunSessionAsync;
|
||||||
|
httpMcpSession.ServerRunTask = runSessionAsync(context, mcpServer, cancellationToken);
|
||||||
|
await httpMcpSession.ServerRunTask;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await transportTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// RequestAborted always triggers when the client disconnects before a complete response body is written,
|
||||||
|
// but this is how SSE connections are typically closed.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_sessions.TryRemove(sessionId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleMessageRequestAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (!context.Request.Query.TryGetValue("sessionId", out var sessionId))
|
||||||
|
{
|
||||||
|
await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_sessions.TryGetValue(sessionId.ToString(), out var httpMcpSession))
|
||||||
|
{
|
||||||
|
await Results.BadRequest($"Session ID not found.").ExecuteAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!httpMcpSession.HasSameUserId(context.User))
|
||||||
|
{
|
||||||
|
await Results.Forbid().ExecuteAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = (JsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), context.RequestAborted);
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await httpMcpSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted);
|
||||||
|
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||||
|
await context.Response.WriteAsync("Accepted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ModelContextProtocol.Protocol.Messages;
|
using ModelContextProtocol.Protocol.Messages;
|
||||||
|
|
@ -10,7 +9,9 @@ using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Utils.Json;
|
using ModelContextProtocol.Utils.Json;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.IO.Pipelines;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore;
|
namespace ModelContextProtocol.AspNetCore;
|
||||||
|
|
||||||
|
|
@ -18,130 +19,207 @@ internal sealed class StreamableHttpHandler(
|
||||||
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
|
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
|
||||||
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
|
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
|
||||||
IOptions<HttpServerTransportOptions> httpMcpServerOptions,
|
IOptions<HttpServerTransportOptions> httpMcpServerOptions,
|
||||||
IHostApplicationLifetime hostApplicationLifetime,
|
ILoggerFactory loggerFactory,
|
||||||
ILoggerFactory loggerFactory)
|
IServiceProvider applicationServices)
|
||||||
{
|
{
|
||||||
|
private static JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, HttpMcpSession> _sessions = new(StringComparer.Ordinal);
|
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
|
||||||
private readonly ILogger _logger = loggerFactory.CreateLogger<StreamableHttpHandler>();
|
|
||||||
|
|
||||||
public async Task HandleRequestAsync(HttpContext context)
|
public async Task HandlePostRequestAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
if (context.Request.Method == HttpMethods.Get)
|
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
|
||||||
|
// ASP.NET Core Minimal APIs mostly ry to stay out of the business of response content negotiation, so
|
||||||
|
// we have to do this manually. The spec doesn't mandate that servers MUST reject these requests, but it's
|
||||||
|
// probably good to at least start out trying to be strict.
|
||||||
|
var acceptHeader = context.Request.Headers.Accept.ToString();
|
||||||
|
if (!acceptHeader.Contains("application/json", StringComparison.Ordinal) ||
|
||||||
|
!acceptHeader.Contains("text/event-stream", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
await HandleSseRequestAsync(context);
|
await WriteJsonRpcErrorAsync(context,
|
||||||
|
"Not Acceptable: Client must accept both application/json and text/event-stream",
|
||||||
|
StatusCodes.Status406NotAcceptable);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else if (context.Request.Method == HttpMethods.Post)
|
|
||||||
|
var session = await GetOrCreateSessionAsync(context);
|
||||||
|
if (session is null)
|
||||||
{
|
{
|
||||||
await HandleMessageRequestAsync(context);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var _ = session.AcquireReference();
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, session.SessionClosed);
|
||||||
|
InitializeSseResponse(context);
|
||||||
|
var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), cts.Token);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleGetRequestAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var acceptHeader = context.Request.Headers.Accept.ToString();
|
||||||
|
if (!acceptHeader.Contains("application/json", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await WriteJsonRpcErrorAsync(context,
|
||||||
|
"Not Acceptable: Client must accept text/event-stream",
|
||||||
|
StatusCodes.Status406NotAcceptable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
|
||||||
|
var session = await GetSessionAsync(context, sessionId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.TryStartGetRequest())
|
||||||
|
{
|
||||||
|
await WriteJsonRpcErrorAsync(context,
|
||||||
|
"Bad Request: This server does not support multiple GET requests. Start a new session to get a new GET SSE response.",
|
||||||
|
StatusCodes.Status400BadRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var _ = session.AcquireReference();
|
||||||
|
InitializeSseResponse(context);
|
||||||
|
|
||||||
|
// We should flush headers to indicate a 200 success quickly, because the initialization response
|
||||||
|
// will be sent in response to a different POST request. It might be a while before we send a message
|
||||||
|
// over this response body.
|
||||||
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||||
|
await session.Transport.HandleGetRequest(context.Response.Body, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleDeleteRequestAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
|
||||||
|
if (Sessions.TryRemove(sessionId, out var session))
|
||||||
|
{
|
||||||
|
await session.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeSessionResponse(HttpContext context, HttpMcpSession<StreamableHttpServerTransport> session)
|
||||||
|
{
|
||||||
|
context.Response.Headers["mcp-session-id"] = session.Id;
|
||||||
|
context.Features.Set(session.Server);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
InitializeSessionResponse(context, existingSession);
|
||||||
|
return existingSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetOrCreateSessionAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
|
||||||
|
HttpMcpSession<StreamableHttpServerTransport>? session;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
|
return await GetSessionAsync(context, sessionId);
|
||||||
await context.Response.WriteAsync("Method Not Allowed");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task HandleSseRequestAsync(HttpContext context)
|
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSessionAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
// If the server is shutting down, we need to cancel all SSE connections immediately without waiting for HostOptions.ShutdownTimeout
|
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
|
||||||
// which defaults to 30 seconds.
|
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
|
||||||
using var sseCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, hostApplicationLifetime.ApplicationStopping);
|
{
|
||||||
var cancellationToken = sseCts.Token;
|
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
|
||||||
|
await configureSessionOptions(context, mcpServerOptions, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
var response = context.Response;
|
var transport = new StreamableHttpServerTransport();
|
||||||
response.Headers.ContentType = "text/event-stream";
|
// Use application instead of request services, because the session will likely outlive the first initialization request.
|
||||||
response.Headers.CacheControl = "no-cache,no-store";
|
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, applicationServices);
|
||||||
|
|
||||||
// Make sure we disable all response buffering for SSE
|
var session = new HttpMcpSession<StreamableHttpServerTransport>(MakeNewSessionId(), transport, context.User, httpMcpServerOptions.Value.TimeProvider)
|
||||||
|
{
|
||||||
|
Server = server,
|
||||||
|
};
|
||||||
|
|
||||||
|
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? RunSessionAsync;
|
||||||
|
session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed);
|
||||||
|
|
||||||
|
InitializeSessionResponse(context, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000)
|
||||||
|
{
|
||||||
|
var jsonRpcError = new JsonRpcError
|
||||||
|
{
|
||||||
|
Error = new()
|
||||||
|
{
|
||||||
|
Code = errorCode,
|
||||||
|
Message = errorMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void InitializeSseResponse(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Headers.ContentType = "text/event-stream";
|
||||||
|
context.Response.Headers.CacheControl = "no-cache,no-store";
|
||||||
|
|
||||||
|
// Make sure we disable all response buffering for SSE.
|
||||||
context.Response.Headers.ContentEncoding = "identity";
|
context.Response.Headers.ContentEncoding = "identity";
|
||||||
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
|
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
|
||||||
|
|
||||||
var sessionId = MakeNewSessionId();
|
|
||||||
await using var transport = new SseResponseStreamTransport(response.Body, $"message?sessionId={sessionId}");
|
|
||||||
var httpMcpSession = new HttpMcpSession(transport, context.User);
|
|
||||||
if (!_sessions.TryAdd(sessionId, httpMcpSession))
|
|
||||||
{
|
|
||||||
Debug.Fail("Unreachable given good entropy!");
|
|
||||||
throw new InvalidOperationException($"Session with ID '{sessionId}' has already been created.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
|
|
||||||
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
|
|
||||||
{
|
|
||||||
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
|
|
||||||
await configureSessionOptions(context, mcpServerOptions, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var transportTask = transport.RunAsync(cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var mcpServer = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, context.RequestServices);
|
|
||||||
context.Features.Set(mcpServer);
|
|
||||||
|
|
||||||
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? RunSessionAsync;
|
|
||||||
await runSessionAsync(context, mcpServer, cancellationToken);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await transport.DisposeAsync();
|
|
||||||
await transportTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// RequestAborted always triggers when the client disconnects before a complete response body is written,
|
|
||||||
// but this is how SSE connections are typically closed.
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_sessions.TryRemove(sessionId, out _);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task HandleMessageRequestAsync(HttpContext context)
|
internal static string MakeNewSessionId()
|
||||||
{
|
{
|
||||||
if (!context.Request.Query.TryGetValue("sessionId", out var sessionId))
|
|
||||||
{
|
|
||||||
await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_sessions.TryGetValue(sessionId.ToString(), out var httpMcpSession))
|
|
||||||
{
|
|
||||||
await Results.BadRequest($"Session ID not found.").ExecuteAsync(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!httpMcpSession.HasSameUserId(context.User))
|
|
||||||
{
|
|
||||||
await Results.Forbid().ExecuteAsync(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var message = (IJsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)), context.RequestAborted);
|
|
||||||
if (message is null)
|
|
||||||
{
|
|
||||||
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await httpMcpSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted);
|
|
||||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
|
||||||
await context.Response.WriteAsync("Accepted");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
|
|
||||||
=> session.RunAsync(requestAborted);
|
|
||||||
|
|
||||||
private static string MakeNewSessionId()
|
|
||||||
{
|
|
||||||
// 128 bits
|
|
||||||
Span<byte> buffer = stackalloc byte[16];
|
Span<byte> buffer = stackalloc byte[16];
|
||||||
RandomNumberGenerator.Fill(buffer);
|
RandomNumberGenerator.Fill(buffer);
|
||||||
return WebEncoders.Base64UrlEncode(buffer);
|
return WebEncoders.Base64UrlEncode(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
|
||||||
|
=> session.RunAsync(requestAborted);
|
||||||
|
|
||||||
|
private static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
|
||||||
|
|
||||||
|
private sealed class HttpDuplexPipe(HttpContext context) : IDuplexPipe
|
||||||
|
{
|
||||||
|
public PipeReader Input => context.Request.BodyReader;
|
||||||
|
public PipeWriter Output => context.Response.BodyWriter;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using ModelContextProtocol.Protocol.Types;
|
||||||
using ModelContextProtocol.Shared;
|
using ModelContextProtocol.Shared;
|
||||||
using ModelContextProtocol.Utils.Json;
|
using ModelContextProtocol.Utils.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Client;
|
namespace ModelContextProtocol.Client;
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ internal sealed partial class McpClient : McpEndpoint, IMcpClient
|
||||||
|
|
||||||
RequestHandlers.Set(
|
RequestHandlers.Set(
|
||||||
RequestMethods.SamplingCreateMessage,
|
RequestMethods.SamplingCreateMessage,
|
||||||
(request, cancellationToken) => samplingHandler(
|
(request, _, cancellationToken) => samplingHandler(
|
||||||
request,
|
request,
|
||||||
request?.Meta?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
|
request?.Meta?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
|
|
@ -76,7 +77,7 @@ internal sealed partial class McpClient : McpEndpoint, IMcpClient
|
||||||
|
|
||||||
RequestHandlers.Set(
|
RequestHandlers.Set(
|
||||||
RequestMethods.RootsList,
|
RequestMethods.RootsList,
|
||||||
rootsHandler,
|
(request, _, cancellationToken) => rootsHandler(request, cancellationToken),
|
||||||
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
|
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ListRootsResult);
|
McpJsonUtilities.JsonContext.Default.ListRootsResult);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ internal static class Diagnostics
|
||||||
};
|
};
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
internal static ActivityContext ExtractActivityContext(this DistributedContextPropagator propagator, IJsonRpcMessage message)
|
internal static ActivityContext ExtractActivityContext(this DistributedContextPropagator propagator, JsonRpcMessage message)
|
||||||
{
|
{
|
||||||
propagator.ExtractTraceIdAndState(message, ExtractContext, out var traceparent, out var tracestate);
|
propagator.ExtractTraceIdAndState(message, ExtractContext, out var traceparent, out var tracestate);
|
||||||
ActivityContext.TryParse(traceparent, tracestate, true, out var activityContext);
|
ActivityContext.TryParse(traceparent, tracestate, true, out var activityContext);
|
||||||
|
|
@ -71,7 +71,7 @@ internal static class Diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void InjectActivityContext(this DistributedContextPropagator propagator, Activity? activity, IJsonRpcMessage message)
|
internal static void InjectActivityContext(this DistributedContextPropagator propagator, Activity? activity, JsonRpcMessage message)
|
||||||
{
|
{
|
||||||
// noop if activity is null
|
// noop if activity is null
|
||||||
propagator.Inject(activity, message, InjectContext);
|
propagator.Inject(activity, message, InjectContext);
|
||||||
|
|
@ -100,7 +100,7 @@ internal static class Diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool ShouldInstrumentMessage(IJsonRpcMessage message) =>
|
internal static bool ShouldInstrumentMessage(JsonRpcMessage message) =>
|
||||||
ActivitySource.HasListeners() &&
|
ActivitySource.HasListeners() &&
|
||||||
message switch
|
message switch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ public interface IMcpEndpoint : IAsyncDisposable
|
||||||
/// Sends a JSON-RPC message to the connected endpoint.
|
/// Sends a JSON-RPC message to the connected endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">
|
/// <param name="message">
|
||||||
/// The JSON-RPC message to send. This can be any type that implements IJsonRpcMessage, such as
|
/// The JSON-RPC message to send. This can be any type that implements JsonRpcMessage, such as
|
||||||
/// JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, or JsonRpcError.
|
/// JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, or JsonRpcError.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||||
|
|
@ -63,7 +63,7 @@ public interface IMcpEndpoint : IAsyncDisposable
|
||||||
/// The method will serialize the message and transmit it using the underlying transport mechanism.
|
/// The method will serialize the message and transmit it using the underlying transport mechanism.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default);
|
Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Registers a handler to be invoked when a notification for the specified method is received.</summary>
|
/// <summary>Registers a handler to be invoked when a notification for the specified method is received.</summary>
|
||||||
/// <param name="method">The notification method.</param>
|
/// <param name="method">The notification method.</param>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ public static class McpEndpointExtensions
|
||||||
string method,
|
string method,
|
||||||
TParameters parameters,
|
TParameters parameters,
|
||||||
JsonSerializerOptions? serializerOptions = null,
|
JsonSerializerOptions? serializerOptions = null,
|
||||||
RequestId? requestId = null,
|
RequestId requestId = default,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
where TResult : notnull
|
where TResult : notnull
|
||||||
{
|
{
|
||||||
|
|
@ -72,7 +72,7 @@ public static class McpEndpointExtensions
|
||||||
TParameters parameters,
|
TParameters parameters,
|
||||||
JsonTypeInfo<TParameters> parametersTypeInfo,
|
JsonTypeInfo<TParameters> parametersTypeInfo,
|
||||||
JsonTypeInfo<TResult> resultTypeInfo,
|
JsonTypeInfo<TResult> resultTypeInfo,
|
||||||
RequestId? requestId = null,
|
RequestId requestId = default,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
where TResult : notnull
|
where TResult : notnull
|
||||||
{
|
{
|
||||||
|
|
@ -83,15 +83,11 @@ public static class McpEndpointExtensions
|
||||||
|
|
||||||
JsonRpcRequest jsonRpcRequest = new()
|
JsonRpcRequest jsonRpcRequest = new()
|
||||||
{
|
{
|
||||||
|
Id = requestId,
|
||||||
Method = method,
|
Method = method,
|
||||||
Params = JsonSerializer.SerializeToNode(parameters, parametersTypeInfo),
|
Params = JsonSerializer.SerializeToNode(parameters, parametersTypeInfo),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (requestId is { } id)
|
|
||||||
{
|
|
||||||
jsonRpcRequest.Id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonRpcResponse response = await endpoint.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
JsonRpcResponse response = await endpoint.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
||||||
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
|
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
using ModelContextProtocol.Utils.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace ModelContextProtocol.Protocol.Messages;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents any JSON-RPC message used in the Model Context Protocol (MCP).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This interface serves as the foundation for all message types in the JSON-RPC 2.0 protocol
|
|
||||||
/// used by MCP, including requests, responses, notifications, and errors. JSON-RPC is a stateless,
|
|
||||||
/// lightweight remote procedure call (RPC) protocol that uses JSON as its data format.
|
|
||||||
/// </remarks>
|
|
||||||
[JsonConverter(typeof(JsonRpcMessageConverter))]
|
|
||||||
public interface IJsonRpcMessage
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the JSON-RPC protocol version used.
|
|
||||||
/// </summary>
|
|
||||||
string JsonRpc { get; }
|
|
||||||
}
|
|
||||||
|
|
@ -16,16 +16,8 @@ namespace ModelContextProtocol.Protocol.Messages;
|
||||||
/// and optional additional data to provide more context about the error.
|
/// and optional additional data to provide more context about the error.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public record JsonRpcError : IJsonRpcMessageWithId
|
public class JsonRpcError : JsonRpcMessageWithId
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
|
||||||
[JsonPropertyName("jsonrpc")]
|
|
||||||
public string JsonRpc { get; init; } = "2.0";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[JsonPropertyName("id")]
|
|
||||||
public required RequestId Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets detailed error information for the failed request, containing an error code,
|
/// Gets detailed error information for the failed request, containing an error code,
|
||||||
/// message, and optional additional data
|
/// message, and optional additional data
|
||||||
|
|
|
||||||
35
src/ModelContextProtocol/Protocol/Messages/JsonRpcMessage.cs
Normal file
35
src/ModelContextProtocol/Protocol/Messages/JsonRpcMessage.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
|
using ModelContextProtocol.Utils.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Protocol.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents any JSON-RPC message used in the Model Context Protocol (MCP).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This interface serves as the foundation for all message types in the JSON-RPC 2.0 protocol
|
||||||
|
/// used by MCP, including requests, responses, notifications, and errors. JSON-RPC is a stateless,
|
||||||
|
/// lightweight remote procedure call (RPC) protocol that uses JSON as its data format.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonConverter(typeof(JsonRpcMessageConverter))]
|
||||||
|
public abstract class JsonRpcMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the JSON-RPC protocol version used.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc />
|
||||||
|
[JsonPropertyName("jsonrpc")]
|
||||||
|
public string JsonRpc { get; init; } = "2.0";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the transport the <see cref="JsonRpcMessage"/> was received on or should be sent over.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is used to support the Streamable HTTP transport where the specification states that the server
|
||||||
|
/// SHOULD include JSON-RPC responses in the HTTP response body for the POST request containing
|
||||||
|
/// the corresponding JSON-RPC request. It may be <see langword="null"/> for other transports.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ITransport? RelatedTransport { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Protocol.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a JSON-RPC message used in the Model Context Protocol (MCP) and that includes an ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// In the JSON-RPC protocol, messages with an ID require a response from the receiver.
|
||||||
|
/// This includes request messages (which expect a matching response) and response messages
|
||||||
|
/// (which include the ID of the original request they're responding to).
|
||||||
|
/// The ID is used to correlate requests with their responses, allowing asynchronous
|
||||||
|
/// communication where multiple requests can be sent without waiting for responses.
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class JsonRpcMessageWithId : JsonRpcMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the message identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each ID is expected to be unique within the context of a given session.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public RequestId Id { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -11,12 +11,8 @@ namespace ModelContextProtocol.Protocol.Messages;
|
||||||
/// They are useful for one-way communication, such as log notifications and progress updates.
|
/// They are useful for one-way communication, such as log notifications and progress updates.
|
||||||
/// Unlike requests, notifications do not include an ID field, since there will be no response to match with it.
|
/// Unlike requests, notifications do not include an ID field, since there will be no response to match with it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public record JsonRpcNotification : IJsonRpcMessage
|
public class JsonRpcNotification : JsonRpcMessage
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
|
||||||
[JsonPropertyName("jsonrpc")]
|
|
||||||
public string JsonRpc { get; init; } = "2.0";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the notification method.
|
/// Gets or sets the name of the notification method.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,8 @@ namespace ModelContextProtocol.Protocol.Messages;
|
||||||
/// and return either a <see cref="JsonRpcResponse"/> with the result, or a <see cref="JsonRpcError"/>
|
/// and return either a <see cref="JsonRpcResponse"/> with the result, or a <see cref="JsonRpcError"/>
|
||||||
/// if the method execution fails.
|
/// if the method execution fails.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public record JsonRpcRequest : IJsonRpcMessageWithId
|
public class JsonRpcRequest : JsonRpcMessageWithId
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
|
||||||
[JsonPropertyName("jsonrpc")]
|
|
||||||
public string JsonRpc { get; init; } = "2.0";
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
[JsonPropertyName("id")]
|
|
||||||
public RequestId Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Name of the method to invoke.
|
/// Name of the method to invoke.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -35,4 +27,15 @@ public record JsonRpcRequest : IJsonRpcMessageWithId
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("params")]
|
[JsonPropertyName("params")]
|
||||||
public JsonNode? Params { get; init; }
|
public JsonNode? Params { get; init; }
|
||||||
|
|
||||||
|
internal JsonRpcRequest WithId(RequestId id)
|
||||||
|
{
|
||||||
|
return new JsonRpcRequest
|
||||||
|
{
|
||||||
|
JsonRpc = JsonRpc,
|
||||||
|
Id = id,
|
||||||
|
Method = Method,
|
||||||
|
Params = Params
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,8 @@ namespace ModelContextProtocol.Protocol.Messages;
|
||||||
/// This class represents a successful response with a result. For error responses, see <see cref="JsonRpcError"/>.
|
/// This class represents a successful response with a result. For error responses, see <see cref="JsonRpcError"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public record JsonRpcResponse : IJsonRpcMessageWithId
|
public class JsonRpcResponse : JsonRpcMessageWithId
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
|
||||||
[JsonPropertyName("jsonrpc")]
|
|
||||||
public string JsonRpc { get; init; } = "2.0";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[JsonPropertyName("id")]
|
|
||||||
public required RequestId Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the result of the method invocation.
|
/// Gets the result of the method invocation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -26,22 +26,6 @@ namespace ModelContextProtocol.Protocol.Transport;
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public interface ITransport : IAsyncDisposable
|
public interface ITransport : IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Gets whether the transport is currently connected and able to send/receive messages.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The <see cref="IsConnected"/> property indicates the current state of the transport connection.
|
|
||||||
/// When <see langword="true"/>, the transport is ready to send and receive messages. When <see langword="false"/>,
|
|
||||||
/// any attempt to send messages will typically result in exceptions being thrown.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The property transitions to <see langword="true"/> when the transport successfully establishes a connection,
|
|
||||||
/// and transitions to <see langword="false"/> when the transport is disposed or encounters a connection error.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
bool IsConnected { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a channel reader for receiving messages from the transport.
|
/// Gets a channel reader for receiving messages from the transport.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -56,7 +40,7 @@ public interface ITransport : IAsyncDisposable
|
||||||
/// any already transmitted messages are consumed.
|
/// any already transmitted messages are consumed.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
ChannelReader<IJsonRpcMessage> MessageReader { get; }
|
ChannelReader<JsonRpcMessage> MessageReader { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a JSON-RPC message through the transport.
|
/// Sends a JSON-RPC message through the transport.
|
||||||
|
|
@ -76,5 +60,5 @@ public interface ITransport : IAsyncDisposable
|
||||||
/// rather than accessing this method directly.
|
/// rather than accessing this method directly.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default);
|
Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,21 +68,21 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task SendMessageAsync(
|
public override async Task SendMessageAsync(
|
||||||
IJsonRpcMessage message,
|
JsonRpcMessage message,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_messageEndpoint == null)
|
if (_messageEndpoint == null)
|
||||||
throw new InvalidOperationException("Transport not connected");
|
throw new InvalidOperationException("Transport not connected");
|
||||||
|
|
||||||
using var content = new StringContent(
|
using var content = new StringContent(
|
||||||
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage),
|
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
|
||||||
Encoding.UTF8,
|
Encoding.UTF8,
|
||||||
"application/json"
|
"application/json"
|
||||||
);
|
);
|
||||||
|
|
||||||
string messageId = "(no id)";
|
string messageId = "(no id)";
|
||||||
|
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
if (message is JsonRpcMessageWithId messageWithId)
|
||||||
{
|
{
|
||||||
messageId = messageWithId.Id.ToString();
|
messageId = messageWithId.Id.ToString();
|
||||||
}
|
}
|
||||||
|
|
@ -213,17 +213,20 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// Normal shutdown
|
|
||||||
LogTransportReadMessagesCancelled(Name);
|
|
||||||
_connectionEstablished.TrySetCanceled(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
LogTransportReadMessagesFailed(Name, ex);
|
if (cancellationToken.IsCancellationRequested)
|
||||||
_connectionEstablished.TrySetException(ex);
|
{
|
||||||
throw;
|
// Normal shutdown
|
||||||
|
LogTransportReadMessagesCancelled(Name);
|
||||||
|
_connectionEstablished.TrySetCanceled(cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LogTransportReadMessagesFailed(Name, ex);
|
||||||
|
_connectionEstablished.TrySetException(ex);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -241,7 +244,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage);
|
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
|
||||||
if (message == null)
|
if (message == null)
|
||||||
{
|
{
|
||||||
LogTransportMessageParseUnexpectedTypeSensitive(Name, data);
|
LogTransportMessageParseUnexpectedTypeSensitive(Name, data);
|
||||||
|
|
@ -249,7 +252,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
|
|
||||||
string messageId = "(no id)";
|
string messageId = "(no id)";
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
if (message is JsonRpcMessageWithId messageWithId)
|
||||||
{
|
{
|
||||||
messageId = messageWithId.Id.ToString();
|
messageId = messageWithId.Id.ToString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue