Add StreamableHttpHandler and WithHttpTransport (#291)
* Simplify ModelContextProtocol.AspNetCore README * Add StreamableHttpHandler * Use "docker info" in CheckIsDockerAvailable * Call WithHttpTransport in samples, tests and README * Cleanup test namespaces * Add CanConnect_WithMcpClient_AfterCustomizingRoute test * Simplify relative URI handling * Handle request made directly to the MapMcp route pattern * Add Messages_FromNewUser_AreRejected test * Fix README * Shorten UserIdClaim ValueTuple names * Remove MaxReconnectAttempts and ReconnectDelay from SseClientTransportOptions - Add proper AdditionalHeaders support
This commit is contained in:
parent
bca5d2609d
commit
9c124b1770
24 changed files with 597 additions and 324 deletions
|
|
@ -5,6 +5,7 @@ using OpenTelemetry;
|
|||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<EchoTool>()
|
||||
.WithTools<SampleLlmTool>();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for configuring HTTP MCP servers via dependency injection.
|
||||
/// </summary>
|
||||
public static class HttpMcpServerBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 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>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="configureOptions">Configures options for the HTTP Streaming transport. This allows configuring per-session
|
||||
/// <see cref="McpServerOptions"/> and running logic before and after a session.</param>
|
||||
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
||||
public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder, Action<HttpServerTransportOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
builder.Services.TryAddSingleton<StreamableHttpHandler>();
|
||||
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
builder.Services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
39
src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs
Normal file
39
src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using ModelContextProtocol.Protocol.Transport;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore;
|
||||
|
||||
internal class HttpMcpSession
|
||||
{
|
||||
public HttpMcpSession(SseResponseStreamTransport transport, ClaimsPrincipal user)
|
||||
{
|
||||
Transport = transport;
|
||||
UserIdClaim = GetUserIdClaim(user);
|
||||
}
|
||||
|
||||
public SseResponseStreamTransport Transport { get; }
|
||||
public (string Type, string Value, string Issuer)? UserIdClaim { get; }
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/>.
|
||||
/// which implements the Streaming HTTP transport for the Model Context Protocol.
|
||||
/// See the protocol specification for details on the Streamable HTTP transport. <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http"/>
|
||||
/// </summary>
|
||||
public class HttpServerTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets an optional asynchronous callback to configure per-session <see cref="McpServerOptions"/>
|
||||
/// with access to the <see cref="HttpContext"/> of the request that initiated the session.
|
||||
/// </summary>
|
||||
public Func<HttpContext, McpServerOptions, CancellationToken, Task>? ConfigureSessionOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional asynchronous callback for running new MCP sessions manually.
|
||||
/// This is useful for running logic before a sessions starts and after it completes.
|
||||
/// </summary>
|
||||
public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
|
||||
}
|
||||
|
|
@ -1,19 +1,7 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Routing.Patterns;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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 ModelContextProtocol.AspNetCore;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Microsoft.AspNetCore.Builder;
|
||||
|
||||
|
|
@ -24,136 +12,20 @@ public static class McpEndpointRouteBuilderExtensions
|
|||
{
|
||||
/// <summary>
|
||||
/// Sets up endpoints for handling MCP HTTP Streaming 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.
|
||||
/// </summary>
|
||||
/// <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="configureOptionsAsync">Configure per-session options.</param>
|
||||
/// <param name="runSessionAsync">Provides an optional asynchronous callback for handling new MCP sessions.</param>
|
||||
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
|
||||
public static IEndpointConventionBuilder MapMcp(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
[StringSyntax("Route")] string pattern = "",
|
||||
Func<HttpContext, McpServerOptions, CancellationToken, Task>? configureOptionsAsync = null,
|
||||
Func<HttpContext, IMcpServer, CancellationToken, Task>? runSessionAsync = null)
|
||||
=> endpoints.MapMcp(RoutePatternFactory.Parse(pattern), configureOptionsAsync, runSessionAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Sets up endpoints for handling MCP HTTP Streaming transport.
|
||||
/// </summary>
|
||||
/// <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="configureOptionsAsync">Configure per-session options.</param>
|
||||
/// <param name="runSessionAsync">Provides an optional asynchronous callback for handling new MCP sessions.</param>
|
||||
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
|
||||
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints,
|
||||
RoutePattern pattern,
|
||||
Func<HttpContext, McpServerOptions, CancellationToken, Task>? configureOptionsAsync = null,
|
||||
Func<HttpContext, IMcpServer, CancellationToken, Task>? runSessionAsync = null)
|
||||
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
|
||||
{
|
||||
ConcurrentDictionary<string, SseResponseStreamTransport> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var optionsSnapshot = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
|
||||
var optionsFactory = endpoints.ServiceProvider.GetRequiredService<IOptionsFactory<McpServerOptions>>();
|
||||
var hostApplicationLifetime = endpoints.ServiceProvider.GetRequiredService<IHostApplicationLifetime>();
|
||||
var handler = 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.");
|
||||
|
||||
var routeGroup = endpoints.MapGroup(pattern);
|
||||
|
||||
routeGroup.MapGet("/sse", async context =>
|
||||
{
|
||||
// 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;
|
||||
|
||||
var response = context.Response;
|
||||
response.Headers.ContentType = "text/event-stream";
|
||||
response.Headers.CacheControl = "no-cache,no-store";
|
||||
|
||||
// Make sure we disable all response buffering for SSE
|
||||
context.Response.Headers.ContentEncoding = "identity";
|
||||
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
|
||||
|
||||
var sessionId = MakeNewSessionId();
|
||||
await using var transport = new SseResponseStreamTransport(response.Body, $"/message?sessionId={sessionId}");
|
||||
if (!_sessions.TryAdd(sessionId, transport))
|
||||
{
|
||||
throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
|
||||
}
|
||||
|
||||
var options = optionsSnapshot.Value;
|
||||
if (configureOptionsAsync is not null)
|
||||
{
|
||||
options = optionsFactory.Create(Options.DefaultName);
|
||||
await configureOptionsAsync.Invoke(context, options, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var transportTask = transport.RunAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var mcpServer = McpServerFactory.Create(transport, options, loggerFactory, endpoints.ServiceProvider);
|
||||
context.Features.Set(mcpServer);
|
||||
|
||||
runSessionAsync ??= RunSession;
|
||||
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 _);
|
||||
}
|
||||
});
|
||||
|
||||
routeGroup.MapPost("/message", async 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 transport))
|
||||
{
|
||||
await Results.BadRequest($"Session ID not found.").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 transport.OnMessageReceivedAsync(message, context.RequestAborted);
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
await context.Response.WriteAsync("Accepted");
|
||||
});
|
||||
|
||||
routeGroup.MapGet("", handler.HandleRequestAsync);
|
||||
routeGroup.MapGet("/sse", handler.HandleRequestAsync);
|
||||
routeGroup.MapPost("/message", handler.HandleRequestAsync);
|
||||
return routeGroup;
|
||||
}
|
||||
|
||||
private static Task RunSession(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
|
||||
=> session.RunAsync(requestAborted);
|
||||
|
||||
private static string MakeNewSessionId()
|
||||
{
|
||||
// 128 bits
|
||||
Span<byte> buffer = stackalloc byte[16];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return WebEncoders.Base64UrlEncode(buffer);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,16 +34,14 @@ using ModelContextProtocol.Server;
|
|||
using System.ComponentModel;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenLocalhost(3001);
|
||||
});
|
||||
builder.Services.AddMcpServer().WithToolsFromAssembly();
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithToolsFromAssembly();
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
app.Run();
|
||||
app.Run("http://localhost:3001");
|
||||
|
||||
[McpServerToolType]
|
||||
public static class EchoTool
|
||||
|
|
|
|||
145
src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Normal file
145
src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
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.Security.Cryptography;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore;
|
||||
|
||||
internal sealed class StreamableHttpHandler(
|
||||
IOptions<McpServerOptions> mcpServerOptionsSnapshot,
|
||||
IOptionsFactory<McpServerOptions> mcpServerOptionsFactory,
|
||||
IOptions<HttpServerTransportOptions> httpMcpServerOptions,
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
|
||||
private readonly ConcurrentDictionary<string, HttpMcpSession> _sessions = new(StringComparer.Ordinal);
|
||||
private readonly ILogger _logger = loggerFactory.CreateLogger<StreamableHttpHandler>();
|
||||
|
||||
public async Task HandleRequestAsync(HttpContext context)
|
||||
{
|
||||
if (context.Request.Method == HttpMethods.Get)
|
||||
{
|
||||
await HandleSseRequestAsync(context);
|
||||
}
|
||||
else if (context.Request.Method == HttpMethods.Post)
|
||||
{
|
||||
await HandleMessageRequestAsync(context);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
|
||||
await context.Response.WriteAsync("Method Not Allowed");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleSseRequestAsync(HttpContext context)
|
||||
{
|
||||
// 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;
|
||||
|
||||
var response = context.Response;
|
||||
response.Headers.ContentType = "text/event-stream";
|
||||
response.Headers.CacheControl = "no-cache,no-store";
|
||||
|
||||
// Make sure we disable all response buffering for SSE
|
||||
context.Response.Headers.ContentEncoding = "identity";
|
||||
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))
|
||||
{
|
||||
throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
|
||||
}
|
||||
|
||||
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
|
||||
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
|
||||
{
|
||||
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
|
||||
await configureSessionOptions(context, mcpServerOptions, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
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];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return WebEncoders.Base64UrlEncode(buffer);
|
||||
}
|
||||
}
|
||||
|
|
@ -101,11 +101,12 @@ internal sealed class SseClientSessionTransport : TransportBase
|
|||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
var response = await _httpClient.PostAsync(
|
||||
_messageEndpoint,
|
||||
content,
|
||||
cancellationToken
|
||||
).ConfigureAwait(false);
|
||||
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
CopyAdditionalHeaders(httpRequestMessage.Headers);
|
||||
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
|
|
@ -182,72 +183,52 @@ internal sealed class SseClientSessionTransport : TransportBase
|
|||
|
||||
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
int reconnectAttempts = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested && !IsConnected)
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
CopyAdditionalHeaders(request.Headers);
|
||||
|
||||
if (_options.AdditionalHeaders != null)
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken
|
||||
).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await foreach (SseItem<string> sseEvent in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
switch (sseEvent.EventType)
|
||||
{
|
||||
foreach (var header in _options.AdditionalHeaders)
|
||||
{
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
}
|
||||
case "endpoint":
|
||||
HandleEndpointEvent(sseEvent.Data);
|
||||
break;
|
||||
|
||||
case "message":
|
||||
await ProcessSseMessage(sseEvent.Data, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken
|
||||
).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await foreach (SseItem<string> sseEvent in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
switch (sseEvent.EventType)
|
||||
{
|
||||
case "endpoint":
|
||||
HandleEndpointEvent(sseEvent.Data);
|
||||
break;
|
||||
|
||||
case "message":
|
||||
await ProcessSseMessage(sseEvent.Data, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.TransportReadMessagesCancelled(_endpointName);
|
||||
// Normal shutdown
|
||||
}
|
||||
catch (IOException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.TransportReadMessagesCancelled(_endpointName);
|
||||
// Normal shutdown
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.TransportConnectionError(_endpointName, ex);
|
||||
|
||||
reconnectAttempts++;
|
||||
if (reconnectAttempts >= _options.MaxReconnectAttempts)
|
||||
{
|
||||
throw new McpTransportException("Exceeded reconnect limit", ex);
|
||||
}
|
||||
|
||||
await Task.Delay(_options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
SetConnected(false);
|
||||
catch when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Normal shutdown
|
||||
_connectionEstablished.TrySetCanceled(cancellationToken);
|
||||
_logger.TransportReadMessagesCancelled(_endpointName);
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_connectionEstablished.TrySetException(ex);
|
||||
_logger.TransportConnectionError(_endpointName, ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessSseMessage(string data, CancellationToken cancellationToken)
|
||||
|
|
@ -293,21 +274,8 @@ internal sealed class SseClientSessionTransport : TransportBase
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if data is absolute URI
|
||||
if (data.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || data.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Since the endpoint is an absolute URI, we can use it directly
|
||||
_messageEndpoint = new Uri(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If the endpoint is a relative URI, we need to combine it with the relative path of the SSE endpoint
|
||||
var baseUriBuilder = new UriBuilder(_sseEndpoint);
|
||||
|
||||
|
||||
// Instead of manually concatenating strings, use the Uri class's composition capabilities
|
||||
_messageEndpoint = new Uri(baseUriBuilder.Uri, data);
|
||||
}
|
||||
// If data is an absolute URL, the Uri will be constructed entirely from it and not the _sseEndpoint.
|
||||
_messageEndpoint = new Uri(_sseEndpoint, data);
|
||||
|
||||
// Set connected state
|
||||
SetConnected(true);
|
||||
|
|
@ -319,4 +287,18 @@ internal sealed class SseClientSessionTransport : TransportBase
|
|||
throw new McpTransportException("Failed to parse endpoint event", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyAdditionalHeaders(HttpRequestHeaders headers)
|
||||
{
|
||||
if (_options.AdditionalHeaders is not null)
|
||||
{
|
||||
foreach (var header in _options.AdditionalHeaders)
|
||||
{
|
||||
if (!headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to add header '{header.Key}' with value '{header.Value}' from {nameof(SseClientTransportOptions.AdditionalHeaders)}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -48,37 +48,6 @@ public record SseClientTransportOptions
|
|||
/// </remarks>
|
||||
public TimeSpan ConnectionTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of reconnection attempts for the SSE connection before giving up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property controls how many times the client will attempt to reconnect to the SSE server
|
||||
/// after a connection failure occurs. If all reconnection attempts fail, a
|
||||
/// <see cref="McpTransportException"/> with the message "Exceeded reconnect limit" will be thrown.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Between each reconnection attempt, the client will wait for the duration specified by <see cref="ReconnectDelay"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int MaxReconnectAttempts { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the delay to employ between reconnection attempts when the SSE connection fails.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When a connection to the SSE server is lost or fails, the client will wait for this duration
|
||||
/// before attempting to reconnect. This helps prevent excessive reconnection attempts in quick succession
|
||||
/// which could overload the server or network.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The reconnection process continues until either a successful connection is established or
|
||||
/// the maximum number of reconnection attempts (<see cref="MaxReconnectAttempts"/>) is reached.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public TimeSpan ReconnectDelay { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets custom HTTP headers to include in requests to the SSE server.
|
||||
/// </summary>
|
||||
|
|
|
|||
154
tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
Normal file
154
tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
|
||||
{
|
||||
private async Task<IMcpClient> ConnectAsync(string? path = null)
|
||||
{
|
||||
var sseClientTransportOptions = new SseClientTransportOptions()
|
||||
{
|
||||
Endpoint = new Uri($"http://localhost{path}"),
|
||||
};
|
||||
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
|
||||
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapMcp_ThrowsInvalidOperationException_IfWithHttpTransportIsNotCalled()
|
||||
{
|
||||
Builder.Services.AddMcpServer();
|
||||
await using var app = Builder.Build();
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => app.MapMcp());
|
||||
Assert.StartsWith("You must call WithHttpTransport()", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allows_Customizing_Route()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp("/mcp");
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var response = await HttpClient.GetAsync("http://localhost/mcp/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a", "/a/sse")]
|
||||
[InlineData("/a", "/a/")]
|
||||
[InlineData("/a/", "/a/sse")]
|
||||
[InlineData("/a/", "/a/")]
|
||||
public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePattern, string requestPath)
|
||||
{
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.ServerInfo = new()
|
||||
{
|
||||
Name = "TestCustomRouteServer",
|
||||
Version = "1.0.0",
|
||||
};
|
||||
}).WithHttpTransport();
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(routePattern);
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var mcpClient = await ConnectAsync(requestPath);
|
||||
|
||||
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_UseHttpContextAccessor_InTool()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
|
||||
|
||||
Builder.Services.AddHttpContextAccessor();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.Use(next =>
|
||||
{
|
||||
return async context =>
|
||||
{
|
||||
context.User = CreateUser("TestUser");
|
||||
await next(context);
|
||||
};
|
||||
});
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var mcpClient = await ConnectAsync();
|
||||
|
||||
var response = await mcpClient.CallToolAsync(
|
||||
"EchoWithUserName",
|
||||
new Dictionary<string, object?>() { ["message"] = "Hello world!" },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var content = Assert.Single(response.Content);
|
||||
Assert.Equal("TestUser: Hello world!", content.Text);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Messages_FromNewUser_AreRejected()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
|
||||
|
||||
// Add an authentication scheme that will send a 403 Forbidden response.
|
||||
Builder.Services.AddAuthentication().AddBearerToken();
|
||||
Builder.Services.AddHttpContextAccessor();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.Use(next =>
|
||||
{
|
||||
var i = 0;
|
||||
return async context =>
|
||||
{
|
||||
context.User = CreateUser($"TestUser{Interlocked.Increment(ref i)}");
|
||||
await next(context);
|
||||
};
|
||||
});
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(() => ConnectAsync());
|
||||
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
|
||||
}
|
||||
|
||||
private ClaimsPrincipal CreateUser(string name)
|
||||
=> new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
|
||||
"TestAuthType", "name", "role"));
|
||||
|
||||
[McpServerToolType]
|
||||
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
|
||||
{
|
||||
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
|
||||
public string EchoWithUserName(string message)
|
||||
{
|
||||
var httpContext = contextAccessor.HttpContext ?? throw new Exception("HttpContext unavailable!");
|
||||
var userName = httpContext.User.Identity?.Name ?? "anonymous";
|
||||
return $"{userName}: {message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.Server;
|
||||
|
||||
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Allows_Customizing_Route()
|
||||
{
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp("/mcp");
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var httpClient = CreateHttpClient();
|
||||
using var response = await httpClient.GetAsync("http://localhost/mcp/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,16 +4,16 @@ using Microsoft.AspNetCore.Routing;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TestServerWithHosting.Tools;
|
||||
|
||||
namespace ModelContextProtocol.Tests;
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
|
||||
{
|
||||
|
|
@ -23,23 +23,21 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
Name = "In-memory Test Server",
|
||||
};
|
||||
|
||||
private Task<IMcpClient> ConnectMcpClient(HttpClient httpClient, McpClientOptions? clientOptions = null)
|
||||
private Task<IMcpClient> ConnectMcpClient(HttpClient? httpClient = null, SseClientTransportOptions? transportOptions = null)
|
||||
=> McpClientFactory.CreateAsync(
|
||||
new SseClientTransport(DefaultTransportOptions, httpClient, LoggerFactory),
|
||||
clientOptions,
|
||||
LoggerFactory,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
new SseClientTransport(transportOptions ?? DefaultTransportOptions, httpClient ?? HttpClient, LoggerFactory),
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAndReceiveMessage_InMemoryServer()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var httpClient = CreateHttpClient();
|
||||
await using var mcpClient = await ConnectMcpClient(httpClient);
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
|
||||
// Send a test message through POST endpoint
|
||||
await mcpClient.SendNotificationAsync("test/message", new Envelope { Message = "Hello, SSE!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
@ -54,8 +52,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
MapAbsoluteEndpointUriMcp(app);
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var httpClient = CreateHttpClient();
|
||||
await using var mcpClient = await ConnectMcpClient(httpClient);
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
|
||||
// Send a test message through POST endpoint
|
||||
await mcpClient.SendNotificationAsync("test/message", new Envelope { Message = "Hello, SSE!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
@ -68,20 +65,26 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
{
|
||||
var receivedNotification = new TaskCompletionSource<string?>();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp(runSessionAsync: (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
mcpServer.RegisterNotificationHandler("test/notification", async (notification, cancellationToken) =>
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(httpTransportOptions =>
|
||||
{
|
||||
Assert.Equal("Hello from client!", notification.Params?["message"]?.GetValue<string>());
|
||||
await mcpServer.SendNotificationAsync("test/notification", new Envelope { Message = "Hello from server!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: cancellationToken);
|
||||
httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
// We could also use ServerCapabilities.NotificationHandlers, but it's good to have some test coverage of RunSessionHandler.
|
||||
mcpServer.RegisterNotificationHandler("test/notification", async (notification, cancellationToken) =>
|
||||
{
|
||||
Assert.Equal("Hello from client!", notification.Params?["message"]?.GetValue<string>());
|
||||
await mcpServer.SendNotificationAsync("test/notification", new Envelope { Message = "Hello from server!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: cancellationToken);
|
||||
});
|
||||
return mcpServer.RunAsync(cancellationToken);
|
||||
};
|
||||
});
|
||||
return mcpServer.RunAsync(cancellationToken);
|
||||
});
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var httpClient = CreateHttpClient();
|
||||
await using var mcpClient = await ConnectMcpClient(httpClient);
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
|
||||
mcpClient.RegisterNotificationHandler("test/notification", (args, ca) =>
|
||||
{
|
||||
|
|
@ -105,13 +108,14 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
Interlocked.Increment(ref firstOptionsCallbackCallCount);
|
||||
firstOptionsCallbackCallCount++;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<EchoTool>();
|
||||
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
Interlocked.Increment(ref secondOptionsCallbackCallCount);
|
||||
secondOptionsCallbackCallCount++;
|
||||
})
|
||||
.WithTools<SampleLlmTool>();
|
||||
|
||||
|
|
@ -120,8 +124,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var httpClient = CreateHttpClient();
|
||||
await using var mcpClient = await ConnectMcpClient(httpClient);
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
|
||||
// Options can be lazily initialized, but they must be instantiated by the time an MCP client can finish connecting.
|
||||
// Callbacks can be called multiple times if configureOptionsAsync is configured, because that uses the IOptionsFactory,
|
||||
|
|
@ -147,6 +150,78 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
Assert.Equal("hello from client!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdditionalHeaders_AreSent_InGetAndPostRequests()
|
||||
{
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
bool wasGetRequest = false;
|
||||
bool wasPostRequest = false;
|
||||
|
||||
app.Use(next =>
|
||||
{
|
||||
return async context =>
|
||||
{
|
||||
Assert.Equal("Bearer testToken", context.Request.Headers["Authorize"]);
|
||||
if (context.Request.Method == HttpMethods.Get)
|
||||
{
|
||||
wasGetRequest = true;
|
||||
}
|
||||
else if (context.Request.Method == HttpMethods.Post)
|
||||
{
|
||||
wasPostRequest = true;
|
||||
}
|
||||
await next(context);
|
||||
};
|
||||
});
|
||||
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var sseOptions = new SseClientTransportOptions()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "In-memory Test Server",
|
||||
AdditionalHeaders = new()
|
||||
{
|
||||
["Authorize"] = "Bearer testToken"
|
||||
},
|
||||
};
|
||||
|
||||
await using var mcpClient = await ConnectMcpClient(transportOptions: sseOptions);
|
||||
|
||||
Assert.True(wasGetRequest);
|
||||
Assert.True(wasPostRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyAdditionalHeadersKey_Throws_InvalidOpearionException()
|
||||
{
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var sseOptions = new SseClientTransportOptions()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "In-memory Test Server",
|
||||
AdditionalHeaders = new()
|
||||
{
|
||||
[""] = ""
|
||||
},
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => ConnectMcpClient(transportOptions: sseOptions));
|
||||
Assert.Equal("Failed to add header '' with value '' from AdditionalHeaders.", ex.Message);
|
||||
}
|
||||
|
||||
private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Test.Utils;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using ModelContextProtocol.TestSseServer;
|
||||
|
||||
namespace ModelContextProtocol.Tests;
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class SseServerIntegrationTestFixture : IAsyncDisposable
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ using ModelContextProtocol.Tests.Utils;
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace ModelContextProtocol.Tests;
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
using Microsoft.AspNetCore.Http.Features;
|
||||
using System.IO.Pipelines;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Utils;
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
|
||||
public sealed class KestrelInMemoryConnection : ConnectionContext
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
using Microsoft.AspNetCore.Connections;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Utils;
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
|
||||
public class KestrelInMemoryTest : LoggedTest
|
||||
{
|
||||
|
|
@ -18,21 +19,24 @@ public class KestrelInMemoryTest : LoggedTest
|
|||
Builder.Services.RemoveAll<IConnectionListenerFactory>();
|
||||
Builder.Services.AddSingleton<IConnectionListenerFactory>(_inMemoryTransport);
|
||||
Builder.Services.AddSingleton(LoggerProvider);
|
||||
}
|
||||
|
||||
public WebApplicationBuilder Builder { get; }
|
||||
|
||||
public HttpClient CreateHttpClient()
|
||||
{
|
||||
var socketsHttpHandler = new SocketsHttpHandler()
|
||||
HttpClient = new HttpClient(new SocketsHttpHandler()
|
||||
{
|
||||
ConnectCallback = (context, token) =>
|
||||
{
|
||||
var connection = _inMemoryTransport.CreateConnection();
|
||||
return new(connection.ClientStream);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return new HttpClient(socketsHttpHandler);
|
||||
public WebApplicationBuilder Builder { get; }
|
||||
|
||||
public HttpClient HttpClient { get; }
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
HttpClient.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Utils;
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
|
||||
public sealed class KestrelInMemoryTransport : IConnectionListenerFactory, IConnectionListener
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Test.Utils;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Utils;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ModelContextProtocol.Test.Utils;
|
||||
namespace ModelContextProtocol.Tests.Utils;
|
||||
|
||||
public class XunitLoggerProvider(ITestOutputHelper output) : ILoggerProvider
|
||||
{
|
||||
|
|
|
|||
|
|
@ -412,7 +412,8 @@ public class Program
|
|||
builder.Logging.AddProvider(loggerProvider);
|
||||
}
|
||||
|
||||
builder.Services.AddMcpServer(ConfigureOptions);
|
||||
builder.Services.AddMcpServer(ConfigureOptions)
|
||||
.WithHttpTransport();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseRouting();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue