From 3c528be8547fc495ede1bd0b77bd66406d8773dc Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 3 Apr 2026 15:28:23 +0800 Subject: [PATCH 01/77] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=87=87=E6=A0=B7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Ipc/IpcServerTransportSession.cs | 67 +++++++++- .../TouchSocketHttpServerTransportSession.cs | 55 +++++++++ .../Clients/McpClientBuilder.cs | 49 ++++++++ .../CompilerServices/McpJsonContext.cs | 9 ++ .../Protocol/Messages/ContentBlock.cs | 4 +- .../Protocol/Messages/Sampling.cs | 18 +-- .../Servers/IMcpServerPrimitiveContext.cs | 10 ++ .../Servers/McpServerRequestHandlers.cs | 7 ++ .../Servers/McpServerSampling.cs | 115 ++++++++++++++++++ .../Transports/ClientTransportManager.cs | 62 ++++++++++ .../Transports/Http/HttpClientTransport.cs | 41 ++++++- .../Http/LocalHostHttpServerTransport.cs | 97 ++++++++++++++- .../LocalHostHttpServerTransportSession.cs | 58 ++++++++- .../Transports/IClientTransportManager.cs | 8 ++ .../Transports/IServerTransportSession.cs | 24 +++- .../Transports/Stdio/StdioClientTransport.cs | 45 +++++++ .../Transports/Stdio/StdioServerTransport.cs | 69 ++++++++++- .../Stdio/StdioServerTransportSession.cs | 105 ++++++++++++++-- 18 files changed, 801 insertions(+), 42 deletions(-) create mode 100644 src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs index ba9a4cb..40594bf 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs @@ -1,4 +1,6 @@ -using dotnetCampus.Ipc.Pipes; +using System.Collections.Concurrent; +using dotnetCampus.Ipc.Pipes; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Ipc; @@ -8,6 +10,9 @@ namespace DotNetCampus.ModelContextProtocol.Transports.Ipc; /// public class IpcServerTransportSession : IServerTransportSession { + private readonly ConcurrentDictionary> _pendingRequests = []; + private PeerProxy? _peer; + /// /// 创建 DotNetCampus.Ipc 传输层的一个会话。 /// @@ -22,15 +27,75 @@ public IpcServerTransportSession(string sessionId) /// public string SessionId { get; } + /// + public ClientCapabilities? ConnectedClientCapabilities { get; set; } + + /// + /// 设置与此会话关联的 IPC 对端代理,用于 SendRequestAsync 发送消息。 + /// + internal void SetPeer(PeerProxy peer) + { + _peer = peer; + } + /// public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } + /// + public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + { + if (request.Id?.ToString() is not { } id) + { + throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + using var registration = cancellationToken.Register(() => + { + if (_pendingRequests.TryRemove(id, out var removed)) + { + removed.TrySetCanceled(cancellationToken); + } + }); + + try + { + await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pendingRequests.TryRemove(id, out _); + } + } + + /// + public void HandleResponseAsync(JsonRpcResponse response) + { + if (response.Id?.ToString() is not { } id) + { + return; + } + + if (_pendingRequests.TryRemove(id, out var tcs)) + { + tcs.TrySetResult(response); + } + } + /// public ValueTask DisposeAsync() { + foreach (var (_, tcs) in _pendingRequests) + { + tcs.TrySetCanceled(); + } + _pendingRequests.Clear(); return ValueTask.CompletedTask; } } diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs index 1b48b54..a8d1131 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Threading.Channels; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; using DotNetCampus.ModelContextProtocol.Hosting.Logging; @@ -16,12 +18,16 @@ public class TouchSocketHttpServerTransportSession : IServerTransportSession private readonly IServerTransportManager _manager; private readonly Channel _outgoingMessages; private readonly CancellationTokenSource _disposeCts = new(); + private readonly ConcurrentDictionary> _pendingRequests = []; private IMcpLogger Log => _manager.Context.Logger; /// public string SessionId { get; } + /// + public ClientCapabilities? ConnectedClientCapabilities { get; set; } + /// /// 初始化 类的新实例。 /// @@ -48,6 +54,50 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat return _outgoingMessages.Writer.WriteAsync(message, cancellationToken).AsTask(); } + /// + public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + { + if (request.Id?.ToString() is not { } id) + { + throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + using var registration = cancellationToken.Register(() => + { + if (_pendingRequests.TryRemove(id, out var removed)) + { + removed.TrySetCanceled(cancellationToken); + } + }); + + try + { + await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pendingRequests.TryRemove(id, out _); + } + } + + /// + public void HandleResponseAsync(JsonRpcResponse response) + { + if (response.Id?.ToString() is not { } id) + { + return; + } + + if (_pendingRequests.TryRemove(id, out var tcs)) + { + tcs.TrySetResult(response); + } + } + /// /// 运行 SSE 长连接,持续向客户端推送消息,直到连接断开或取消。 /// @@ -123,6 +173,11 @@ public async ValueTask DisposeAsync() _disposeCts.Cancel(); #endif _outgoingMessages.Writer.TryComplete(); + foreach (var (_, tcs) in _pendingRequests) + { + tcs.TrySetCanceled(); + } + _pendingRequests.Clear(); _disposeCts.Dispose(); } } diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs index e77176b..0769760 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs @@ -18,6 +18,7 @@ public class McpClientBuilder private IServiceProvider? _serviceProvider; private Func? _transportFactory; private ClientCapabilities _capabilities = new(); + private Func>? _samplingHandler; /// /// 设置客户端名称和版本。 @@ -131,6 +132,49 @@ public McpClientBuilder WithCapabilities(ClientCapabilities capabilities) return this; } + /// + /// 配置 Sampling 处理器,使客户端支持服务器发起的 sampling/createMessage 请求。
+ /// 调用此方法会自动在客户端能力中声明 Sampling 支持。
+ /// Configures a handler for server-initiated sampling/createMessage requests. + /// Calling this method automatically declares Sampling capability in client capabilities. + ///
+ /// + /// 当服务器请求采样时的处理函数。接收 并返回
+ /// Handler invoked when the server requests sampling. Receives and returns . + /// + /// 用于链式调用的 MCP 客户端生成器。 + public McpClientBuilder WithSamplingHandler( + Func> handler) + { + _samplingHandler = handler; + _capabilities = _capabilities with + { + Sampling = _capabilities.Sampling ?? new SamplingCapability(), + }; + return this; + } + + /// + /// 配置 Sampling 处理器,使客户端支持服务器发起的 sampling/createMessage 请求。
+ /// 调用此方法会自动在客户端能力中声明 Sampling 支持。
+ /// Configures a handler for server-initiated sampling/createMessage requests. + /// Calling this method automatically declares Sampling capability in client capabilities. + ///
+ /// + /// 处理函数工厂,接收 以便从中获取所需服务。
+ /// Handler factory that receives an for resolving dependencies. + /// + /// 用于链式调用的 MCP 客户端生成器。 + public McpClientBuilder WithSamplingHandler( + Func>> handlerFactory) + { + return WithSamplingHandler((p, ct) => + { + var handler = handlerFactory(_serviceProvider); + return handler(p, ct); + }); + } + /// /// 构建 MCP 客户端实例。 /// @@ -151,6 +195,11 @@ public McpClient Build() var transportManager = new ClientTransportManager(context); context.Transport = transportManager; + if (_samplingHandler is { } handler) + { + transportManager.SetSamplingHandler(handler); + } + var transport = _transportFactory(transportManager); transportManager.SetTransport(transport); diff --git a/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs b/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs index 24d0997..cf495fc 100644 --- a/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs @@ -115,19 +115,25 @@ internal partial class McpServerToolJsonContext : JsonSerializerContext; /// 提供给 MCP 协议中,服务端收到来自客户端的请求数据时使用的 JSON 序列化上下文。 ///
[JsonSerializable(typeof(CallToolRequestParams))] +[JsonSerializable(typeof(CreateMessageRequestParams))] [JsonSerializable(typeof(GetPromptRequestParams))] [JsonSerializable(typeof(InitializeRequestParams))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(ListPromptsRequestParams))] [JsonSerializable(typeof(JsonRpcNotification))] [JsonSerializable(typeof(JsonRpcRequest))] +[JsonSerializable(typeof(JsonRpcResponse))] [JsonSerializable(typeof(ListResourcesRequestParams))] [JsonSerializable(typeof(ListResourceTemplatesRequestParams))] [JsonSerializable(typeof(ListToolsRequestParams))] [JsonSerializable(typeof(LoggingLevel))] +[JsonSerializable(typeof(ModelHint))] +[JsonSerializable(typeof(ModelPreferences))] [JsonSerializable(typeof(PingRequestParams))] [JsonSerializable(typeof(ReadResourceRequestParams))] +[JsonSerializable(typeof(SamplingMessage))] [JsonSerializable(typeof(SetLevelRequestParams))] +[JsonSerializable(typeof(ToolChoice))] [JsonSourceGenerationOptions( PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, @@ -144,12 +150,14 @@ internal partial class McpServerRequestJsonContext : JsonSerializerContext; [JsonSerializable(typeof(CallToolResult))] [JsonSerializable(typeof(CompiledJsonSchema))] [JsonSerializable(typeof(ContentBlock))] +[JsonSerializable(typeof(CreateMessageResult))] [JsonSerializable(typeof(EmbeddedResourceContentBlock))] [JsonSerializable(typeof(EmptyObject))] [JsonSerializable(typeof(GetPromptResult))] [JsonSerializable(typeof(ImageContentBlock))] [JsonSerializable(typeof(InitializeResult))] [JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcResponse))] [JsonSerializable(typeof(ListPromptsResult))] [JsonSerializable(typeof(ListResourcesResult))] @@ -159,6 +167,7 @@ internal partial class McpServerRequestJsonContext : JsonSerializerContext; [JsonSerializable(typeof(ReadResourceResult))] [JsonSerializable(typeof(ResourceContents))] [JsonSerializable(typeof(ResourceLinkContentBlock))] +[JsonSerializable(typeof(SamplingMessage))] [JsonSerializable(typeof(TextContentBlock))] [JsonSerializable(typeof(TextResourceContents))] [JsonSourceGenerationOptions( diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs index 68e68db..d518ddc 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs @@ -13,8 +13,8 @@ namespace DotNetCampus.ModelContextProtocol.Protocol.Messages; [JsonDerivedType(typeof(AudioContentBlock), typeDiscriminator: "audio")] [JsonDerivedType(typeof(ResourceLinkContentBlock), typeDiscriminator: "resource_link")] [JsonDerivedType(typeof(EmbeddedResourceContentBlock), typeDiscriminator: "resource")] -[JsonDerivedType(typeof(ToolUseContent), typeDiscriminator: "toolUse")] -[JsonDerivedType(typeof(ToolResultContent), typeDiscriminator: "toolResult")] +[JsonDerivedType(typeof(ToolUseContent), typeDiscriminator: "tool_use")] +[JsonDerivedType(typeof(ToolResultContent), typeDiscriminator: "tool_result")] public abstract record ContentBlock { /// diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Sampling.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Sampling.cs index cbfdb8f..6df1ccd 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Sampling.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Sampling.cs @@ -123,7 +123,7 @@ public sealed record CreateMessageResult : Result /// Message content /// [JsonPropertyName("content")] - public required SamplingMessageContent Content { get; init; } + public required ContentBlock Content { get; init; } /// /// 生成消息的模型名称。
@@ -161,7 +161,7 @@ public sealed record SamplingMessage /// Message content ///
[JsonPropertyName("content")] - public required SamplingMessageContent Content { get; init; } + public required ContentBlock Content { get; init; } /// /// 元数据字段
@@ -173,20 +173,6 @@ public sealed record SamplingMessage public JsonElement? Meta { get; init; } } -/// -/// 采样消息内容(文本、图像、音频、工具使用或工具结果)
-/// Sampling message content (text, image, audio, tool use or tool result) -///
-[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(TextContentBlock), typeDiscriminator: "text")] -[JsonDerivedType(typeof(ImageContentBlock), typeDiscriminator: "image")] -[JsonDerivedType(typeof(AudioContentBlock), typeDiscriminator: "audio")] -[JsonDerivedType(typeof(ToolUseContent), typeDiscriminator: "toolUse")] -[JsonDerivedType(typeof(ToolResultContent), typeDiscriminator: "toolResult")] -public abstract record SamplingMessageContent -{ -} - /// /// 服务器在采样期间对模型选择的偏好,在采样期间请求客户端。
/// 由于 LLM 可以在多个维度上变化,选择"最佳"模型很少是直截了当的。
diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs index ed0ff43..d591dcc 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs @@ -4,6 +4,7 @@ namespace DotNetCampus.ModelContextProtocol.Servers; + /// /// 包含 MCP 服务器收到来自客户端的请求时,服务端处理请求具体实现可能会用到的各种上下文信息。
/// Contains various context information that the server-side implementation of the MCP server @@ -60,6 +61,13 @@ public interface IMcpServerCallToolContext : IMcpServerPrimitiveContext /// Cancellation token used to cancel the tool invocation operation. ///
CancellationToken CancellationToken { get; } + + /// + /// 提供服务器向客户端发起 Sampling 请求的能力。如果当前传输层或客户端不支持 Sampling,调用相关方法将抛出异常。
+ /// Provides the ability to send Sampling requests from the server to the client. + /// If the current transport or client does not support Sampling, calling the related methods will throw an exception. + ///
+ IMcpServerSampling Sampling { get; } } /// @@ -92,6 +100,8 @@ internal sealed class McpServerCallToolContext : IMcpServerCallToolContext public required string Name { get; init; } public required JsonElement InputJsonArguments { get; init; } public required CancellationToken CancellationToken { get; init; } + public IMcpServerSampling Sampling => (IMcpServerSampling?)Services.GetService(typeof(IMcpServerSampling)) + ?? throw new InvalidOperationException("当前传输层未提供 IMcpServerSampling 服务,或客户端未声明 Sampling 能力。The current transport has not provided IMcpServerSampling, or the client has not declared Sampling capability."); } internal sealed class McpServerReadResourceContext : IMcpServerReadResourceContext diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs index e311b93..c52bb2a 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs @@ -64,6 +64,13 @@ public virtual ValueTask InitializeAsync( var clientInfo = request.Params?.ClientInfo; Logger.Info($"[McpServer][Mcp] Client initializing. ClientName={clientInfo?.Name}, ClientVersion={clientInfo?.Version}, ProtocolVersion={request.Params?.ProtocolVersion}"); + // 将客户端能力保存到当前传输层会话,以便后续服务器发起请求(如 sampling)时判断能力。 + var session = (DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession?)request.Services.GetService(typeof(DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession)); + if (session is not null && request.Params?.Capabilities is { } capabilities) + { + session.ConnectedClientCapabilities = capabilities; + } + var hasTools = _server.Tools.Count > 0; var hasResources = _server.Resources.Count > 0; diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs new file mode 100644 index 0000000..42966f2 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Exceptions; +using DotNetCampus.ModelContextProtocol.Protocol; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using DotNetCampus.ModelContextProtocol.Transports; +using DotNetCampus.ModelContextProtocol.Utils; + +namespace DotNetCampus.ModelContextProtocol.Servers; + +/// +/// 提供服务器主动向客户端发起 Sampling(AI 采样)请求的能力。
+/// Provides the server's ability to initiate Sampling (AI sampling) requests to the client. +///
+public interface IMcpServerSampling +{ + /// + /// 指示连接的客户端是否声明了对 Sampling 的支持。
+ /// Indicates whether the connected client has declared support for Sampling. + ///
+ bool HasSamplingCapability { get; } + + /// + /// 向客户端发送 sampling/createMessage 请求,通过客户端对 LLM 进行采样。
+ /// Sends a sampling/createMessage request to the client to sample from an LLM via the client. + ///
+ /// 采样请求参数。Sampling request parameters. + /// 取消令牌。Cancellation token. + /// LLM 生成的采样结果。The LLM-generated sampling result. + /// 当客户端未声明 Sampling 能力时抛出。Thrown when the client has not declared Sampling capability. + Task CreateMessageAsync(CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default); +} + +/// +/// 的扩展方法,提供便捷的文本采样接口。
+/// Extension methods for , providing convenient text-only sampling APIs. +///
+public static class McpServerSamplingExtensions +{ + /// + /// 向客户端发送简单的纯文本采样请求。
+ /// Sends a simple plain-text sampling request to the client. + ///
+ /// 采样服务实例。Sampling service instance. + /// 用户消息内容。User message content. + /// 最大生成令牌数。Maximum number of tokens to generate. + /// 可选的系统提示词。Optional system prompt. + /// 取消令牌。Cancellation token. + /// LLM 生成的采样结果。The LLM-generated sampling result. + public static Task CreateMessageAsync( + this IMcpServerSampling sampling, + string userMessage, + int maxTokens = 1024, + string? systemPrompt = null, + CancellationToken cancellationToken = default) + { + var requestParams = new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = userMessage }, + }, + ], + MaxTokens = maxTokens, + SystemPrompt = systemPrompt, + }; + return sampling.CreateMessageAsync(requestParams, cancellationToken); + } +} + +/// +/// 的内部实现,通过关联的传输层会话与客户端通信。 +/// +internal sealed class McpServerSampling(IServerTransportSession session) : IMcpServerSampling +{ + /// + public bool HasSamplingCapability => session.ConnectedClientCapabilities?.Sampling is not null; + + /// + public async Task CreateMessageAsync( + CreateMessageRequestParams requestParams, + CancellationToken cancellationToken = default) + { + if (!HasSamplingCapability) + { + throw new InvalidOperationException("连接的客户端未声明对 Sampling 的支持。The connected client has not declared Sampling capability."); + } + + var request = new JsonRpcRequest + { + Id = RequestId.MakeNew().ToJsonElement(), + Method = RequestMethods.SamplingCreateMessage, + Params = JsonSerializer.SerializeToElement(requestParams, McpServerRequestJsonContext.Default.CreateMessageRequestParams), + }; + + var response = await session.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.Error is { } error) + { + throw new McpClientException($"Sampling request failed: [{error.Code}] {error.Message}"); + } + + if (response.Result is not { } resultElement) + { + throw new McpClientException("Sampling response missing result."); + } + + return resultElement.Deserialize(McpServerResponseJsonContext.Default.CreateMessageResult) + ?? throw new McpClientException("Failed to deserialize sampling result."); + } +} diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 0b38213..b140236 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -17,6 +17,7 @@ internal class ClientTransportManager(IClientTransportContext context) : IClient { private readonly ConcurrentDictionary> _pendingRequests = []; private IClientTransport? _transport; + private Func>? _samplingHandler; /// public IClientTransportContext Context { get; } = context; @@ -29,6 +30,14 @@ internal void SetTransport(IClientTransport transport) _transport = transport; } + /// + /// 设置 Sampling 请求处理器,供服务器主动发起 sampling/createMessage 请求时调用。 + /// + internal void SetSamplingHandler(Func> handler) + { + _samplingHandler = handler; + } + /// public RequestId MakeNewRequestId() { @@ -53,6 +62,7 @@ public RequestId MakeNewRequestId() public string WriteMessageAsync(JsonRpcMessage message) => message switch { JsonRpcRequest request => JsonSerializer.Serialize(request, McpServerRequestJsonContext.Default.JsonRpcRequest), + JsonRpcResponse response => JsonSerializer.Serialize(response, McpServerResponseJsonContext.Default.JsonRpcResponse), JsonRpcNotification notification => JsonSerializer.Serialize(notification, McpServerRequestJsonContext.Default.JsonRpcNotification), _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), }; @@ -64,6 +74,8 @@ public async ValueTask WriteMessageAsync(Stream requestStream, JsonRpcMessage me { JsonRpcRequest request => JsonSerializer.SerializeAsync( requestStream, request, McpServerRequestJsonContext.Default.JsonRpcRequest, cancellationToken), + JsonRpcResponse response => JsonSerializer.SerializeAsync( + requestStream, response, McpServerResponseJsonContext.Default.JsonRpcResponse, cancellationToken), JsonRpcNotification notification => JsonSerializer.SerializeAsync( requestStream, notification, McpServerRequestJsonContext.Default.JsonRpcNotification, cancellationToken), _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), @@ -87,7 +99,57 @@ public ValueTask HandleRespondAsync(JsonRpcResponse response, CancellationToken return ValueTask.CompletedTask; } + /// + public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + { + JsonRpcResponse response; + + if (request.Method == RequestMethods.SamplingCreateMessage && _samplingHandler is { } handler) + { + try + { + CreateMessageRequestParams? requestParams = null; + if (request.Params is { } paramsElement) + { + requestParams = paramsElement.Deserialize(McpServerRequestJsonContext.Default.CreateMessageRequestParams); + } + requestParams ??= new CreateMessageRequestParams { Messages = [], MaxTokens = 1024 }; + var result = await handler(requestParams, cancellationToken).ConfigureAwait(false); + response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToElement(result, McpServerResponseJsonContext.Default.CreateMessageResult), + }; + } + catch (Exception ex) + { + response = new JsonRpcResponse + { + Id = request.Id, + Error = new JsonRpcError + { + Code = (int)JsonRpcErrorCode.InternalError, + Message = ex.Message, + }, + }; + } + } + else + { + response = new JsonRpcResponse + { + Id = request.Id, + Error = new JsonRpcError + { + Code = (int)JsonRpcErrorCode.MethodNotFound, + Message = $"Method '{request.Method}' not found or no handler registered.", + }, + }; + } + + await SendMessageAsync(response, cancellationToken).ConfigureAwait(false); + } /// /// 发送请求并等待响应。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index 6f24321..c161ecb 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -395,10 +395,33 @@ private async Task DispatchSseEventAsync(string? eventName, string data, Cancell } } - var response = await _manager.ReadResponseAsync(data); - if (response != null) + // 检测是服务器主动发起的请求(有 method),还是对客户端请求的响应(有 result/error)。 + bool isServerRequest; + try + { + using var doc = JsonDocument.Parse(data); + isServerRequest = doc.RootElement.TryGetProperty("method", out _); + } + catch + { + isServerRequest = false; + } + + if (isServerRequest) + { + var request = TryParseServerRequest(data); + if (request is not null) + { + await _manager.HandleServerRequestAsync(request, token); + } + } + else { - await _manager.HandleRespondAsync(response, token); + var response = await _manager.ReadResponseAsync(data); + if (response != null) + { + await _manager.HandleRespondAsync(response, token); + } } } catch (Exception ex) @@ -407,4 +430,16 @@ private async Task DispatchSseEventAsync(string? eventName, string data, Cancell } } } + + private static JsonRpcRequest? TryParseServerRequest(string json) + { + try + { + return JsonSerializer.Deserialize(json, CompilerServices.McpServerRequestJsonContext.Default.JsonRpcRequest); + } + catch + { + return null; + } + } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 651fbc5..5ca1e50 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -2,9 +2,11 @@ using System.Net; using System.Text; using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using DotNetCampus.ModelContextProtocol.Servers; namespace DotNetCampus.ModelContextProtocol.Transports.Http; @@ -184,10 +186,64 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat } } + // 读取 body 到内存,以便检查是请求还是响应。 + byte[] bodyBytes; + try + { + using var ms = new MemoryStream(); + await request.InputStream.CopyToAsync(ms, cancellationToken); + bodyBytes = ms.ToArray(); + } + catch + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Failed to read request body"); + return; + } + + if (bodyBytes.Length == 0) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); + return; + } + + // 检测是 JSON-RPC 请求(有 method)还是响应(无 method)。 + var isResponse = IsJsonRpcResponseBytes(bodyBytes); + + var sessionIdStr = request.Headers[SessionIdHeader]; + + if (isResponse) + { + // 客户端响应服务器发起的请求(如 sampling/createMessage)。 + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + JsonRpcResponse? jsonRpcResponse; + try + { + jsonRpcResponse = JsonSerializer.Deserialize(bodyBytes, McpServerResponseJsonContext.Default.JsonRpcResponse); + } + catch (JsonException) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON response"); + return; + } + + if (jsonRpcResponse is not null) + { + responseSession.HandleResponseAsync(jsonRpcResponse); + } + + context.RespondHttpSuccess(HttpStatusCode.Accepted); + return; + } + JsonRpcRequest? jsonRpcRequest; try { - jsonRpcRequest = await _manager.ReadRequestAsync(request.InputStream); + jsonRpcRequest = JsonSerializer.Deserialize(bodyBytes, McpServerRequestJsonContext.Default.JsonRpcRequest); } catch (JsonException) { @@ -202,7 +258,6 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat } var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; - var sessionIdStr = request.Headers[SessionIdHeader]; LocalHostHttpServerTransportSession? session; if (isInitialize) @@ -238,16 +293,23 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat } } - var jsonRpcResponse = await _manager.HandleRequestAsync(jsonRpcRequest, cancellationToken: cancellationToken); + var capturedSession = session; + var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddScoped(capturedSession); + s.AddScoped(new McpServerSampling(capturedSession)); + }, + cancellationToken); - if (jsonRpcResponse != null) + if (jsonRpcResponse2 != null) { // Request: Success or Failed. context.Response.ContentType = "application/json"; context.Response.StatusCode = (int)HttpStatusCode.OK; try { - await _manager.WriteMessageAsync(context.Response.OutputStream, jsonRpcResponse, cancellationToken); + await _manager.WriteMessageAsync(context.Response.OutputStream, jsonRpcResponse2, cancellationToken); context.Response.SafeClose(); } catch @@ -262,6 +324,31 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat } } + private static bool IsJsonRpcResponseBytes(byte[] bodyBytes) + { + try + { + var reader = new Utf8JsonReader(bodyBytes); + if (!JsonDocument.TryParseValue(ref reader, out var doc)) + { + return false; + } + using (doc) + { + var root = doc.RootElement; + if (root.TryGetProperty("method", out _)) + { + return false; + } + return root.TryGetProperty("result", out _) || root.TryGetProperty("error", out _); + } + } + catch + { + return false; + } + } + private async Task HandleGetRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) { var request = context.Request; diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs index b28c11f..4390ffc 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs @@ -1,5 +1,7 @@ -using System.Threading.Channels; +using System.Collections.Concurrent; +using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Http; @@ -16,11 +18,15 @@ internal class LocalHostHttpServerTransportSession : IServerTransportSession private readonly IServerTransportManager _manager; private readonly Channel _outgoingMessages; private readonly CancellationTokenSource _disposeCts = new(); + private readonly ConcurrentDictionary> _pendingRequests = []; private IMcpLogger Log => _manager.Context.Logger; public string SessionId { get; } + /// + public ClientCapabilities? ConnectedClientCapabilities { get; set; } + public LocalHostHttpServerTransportSession(IServerTransportManager manager, string sessionId) { _manager = manager; @@ -41,6 +47,51 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat return _outgoingMessages.Writer.WriteAsync(message, cancellationToken).AsTask(); } + /// + public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + { + if (request.Id?.ToString() is not { } id) + { + throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + using var registration = cancellationToken.Register(() => + { + if (_pendingRequests.TryRemove(id, out var removed)) + { + removed.TrySetCanceled(cancellationToken); + } + }); + + try + { + // 通过 SSE 通道将请求发送给客户端。 + await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pendingRequests.TryRemove(id, out _); + } + } + + /// + public void HandleResponseAsync(JsonRpcResponse response) + { + if (response.Id?.ToString() is not { } id) + { + return; + } + + if (_pendingRequests.TryRemove(id, out var tcs)) + { + tcs.TrySetResult(response); + } + } + public async Task RunSseConnectionAsync(Stream outputStream, CancellationToken cancellationToken) { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); @@ -110,6 +161,11 @@ public async ValueTask DisposeAsync() _disposeCts.Cancel(); #endif _outgoingMessages.Writer.TryComplete(); + foreach (var (_, tcs) in _pendingRequests) + { + tcs.TrySetCanceled(); + } + _pendingRequests.Clear(); _disposeCts.Dispose(); } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs index 200174a..5f7f256 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs @@ -77,4 +77,12 @@ public interface IClientTransportManager /// 取消令牌。 /// 此方法绝对不会发生异常。 ValueTask HandleRespondAsync(JsonRpcResponse response, CancellationToken cancellationToken = default); + + /// + /// 提供给传输层调用。当传输层收到来自服务器的 JSON-RPC 请求时(如 sampling/createMessage),调用此方法可以将请求交给 MCP 客户端进行处理并回送响应。
+ /// Called by the transport layer when a server-initiated JSON-RPC request is received (e.g. sampling/createMessage). + ///
+ /// 从传输层解析出来的服务器发起的 JSON-RPC 请求。 + /// 取消令牌。 + ValueTask HandleServerRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs index c454f46..6c84152 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs @@ -1,4 +1,5 @@ -using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports; @@ -15,7 +16,26 @@ public interface IServerTransportSession : IAsyncDisposable string? SessionId { get; } /// - /// 将消息发送给其他端。 + /// 连接的客户端所声明的客户端能力。在 Initialize 握手完成后设置。
+ /// The client capabilities declared by the connected client. Set after the Initialize handshake completes. + ///
+ ClientCapabilities? ConnectedClientCapabilities { get; set; } + + /// + /// 将消息发送给其他端(不期望响应)。
+ /// Sends a message to the other side (no response expected). ///
Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default); + + /// + /// 向客户端发送 JSON-RPC 请求并等待响应。用于服务器主动发起的请求(如 sampling/createMessage)。
+ /// Sends a JSON-RPC request to the client and waits for the response. Used for server-initiated requests (e.g. sampling/createMessage). + ///
+ Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default); + + /// + /// 处理从客户端收到的 JSON-RPC 响应(对服务器发起的请求的回复)。
+ /// Handles a JSON-RPC response received from the client (a reply to a server-initiated request). + ///
+ void HandleResponseAsync(JsonRpcResponse response); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs index 81b43e7..8cccb44 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.Diagnostics.Contracts; using System.Text; +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -98,6 +100,24 @@ private async Task RunLoopAsync(StdioProcessInfo stdio, CancellationToken cancel break; } + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // 检测是服务器主动发起的请求(有 method),还是对客户端请求的响应(有 result/error)。 + if (IsServerRequest(line)) + { + var request = TryParseServerRequest(line); + if (request is null) + { + Log.Warn($"[McpClient][Stdio] Invalid server request received."); + continue; + } + await _manager.HandleServerRequestAsync(request, cancellationToken); + continue; + } + var response = await _manager.ParseAndCatchResponseAsync(line); if (response is null) { @@ -109,6 +129,31 @@ private async Task RunLoopAsync(StdioProcessInfo stdio, CancellationToken cancel } } + private static bool IsServerRequest(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("method", out _); + } + catch + { + return false; + } + } + + private static JsonRpcRequest? TryParseServerRequest(string json) + { + try + { + return JsonSerializer.Deserialize(json, McpServerRequestJsonContext.Default.JsonRpcRequest); + } + catch + { + return null; + } + } + [Pure] private Task StartProcessAsync() { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs index 30bc8d0..5aa1b4e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs @@ -1,6 +1,9 @@ using System.Text; +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using DotNetCampus.ModelContextProtocol.Servers; namespace DotNetCampus.ModelContextProtocol.Transports.Stdio; @@ -50,6 +53,7 @@ public Task StartAsync(CancellationToken startingCancellationToken, Cancel StandardInput = input, StandardOutput = output, }; + _session.SetOutput(output); _manager.Add(_session); return Task.FromResult(RunLoopAsync(runningCancellationToken)); @@ -84,6 +88,24 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) break; } + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // 检测是请求(有 method 字段)还是响应(无 method 字段)。 + // Detect whether it's a request (has "method" field) or a response (no "method" field). + if (IsJsonRpcResponse(line)) + { + // 将响应路由到等待的请求。 + var response = TryParseResponse(line); + if (response is not null) + { + _session.HandleResponseAsync(response); + } + continue; + } + var request = await _manager.ParseAndCatchRequestAsync(line); if (request is null) { @@ -98,15 +120,56 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) continue; } - var response = await _manager.HandleRequestAsync(request, null, cancellationToken); - if (response is null) + var session = _session; + var response2 = await _manager.HandleRequestAsync(request, + s => + { + s.AddScoped(session); + s.AddScoped(new McpServerSampling(session)); + }, + cancellationToken); + if (response2 is null) { // 按照 MCP 协议规范,本次请求仅需响应而无需回复。 await output.WriteLineAsync(); continue; } - await _manager.RespondJsonRpcAsync(output, response, cancellationToken); + await _manager.RespondJsonRpcAsync(output, response2, cancellationToken); + } + } + + /// + /// 判断 JSON 字符串是否为 JSON-RPC 响应(没有 method 字段,有 result 或 error 字段)。 + /// + private static bool IsJsonRpcResponse(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + // 请求必须有 method 字段;响应没有 method 字段但有 result 或 error。 + if (root.TryGetProperty("method", out _)) + { + return false; + } + return root.TryGetProperty("result", out _) || root.TryGetProperty("error", out _); + } + catch + { + return false; + } + } + + private static JsonRpcResponse? TryParseResponse(string json) + { + try + { + return JsonSerializer.Deserialize(json, McpServerResponseJsonContext.Default.JsonRpcResponse); + } + catch + { + return null; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index 7a119de..a0fd23c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -1,4 +1,9 @@ -using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Stdio; @@ -7,12 +12,10 @@ namespace DotNetCampus.ModelContextProtocol.Transports.Stdio; ///
public class StdioServerTransportSession : IServerTransportSession { - /// - /// STDIO 传输层的一个会话。 - /// - public StdioServerTransportSession() - { - } + private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); + private readonly ConcurrentDictionary> _pendingRequests = []; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private StreamWriter? _output; /// /// STDIO 传输层是专用的,不需要会话 ID。 @@ -20,14 +23,98 @@ public StdioServerTransportSession() public string? SessionId => null; /// - public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + public ClientCapabilities? ConnectedClientCapabilities { get; set; } + + /// + /// 由 在启动后设置输出流。 + /// + internal void SetOutput(StreamWriter output) + { + _output = output; + } + + /// + public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + if (_output is not { } output) + { + return; + } + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await JsonSerializer.SerializeAsync(output.BaseStream, message, GetTypeInfo(message), cancellationToken).ConfigureAwait(false); + await output.BaseStream.WriteAsync(NewLineBytes, cancellationToken).ConfigureAwait(false); + await output.BaseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + /// + public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + if (request.Id?.ToString() is not { } id) + { + throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + using var registration = cancellationToken.Register(() => + { + if (_pendingRequests.TryRemove(id, out var removed)) + { + removed.TrySetCanceled(cancellationToken); + } + }); + + try + { + await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pendingRequests.TryRemove(id, out _); + } + } + + /// + public void HandleResponseAsync(JsonRpcResponse response) + { + if (response.Id?.ToString() is not { } id) + { + return; + } + + if (_pendingRequests.TryRemove(id, out var tcs)) + { + tcs.TrySetResult(response); + } } /// public ValueTask DisposeAsync() { + _writeLock.Dispose(); + foreach (var (_, tcs) in _pendingRequests) + { + tcs.TrySetCanceled(); + } + _pendingRequests.Clear(); return ValueTask.CompletedTask; } + + private static System.Text.Json.Serialization.Metadata.JsonTypeInfo GetTypeInfo(JsonRpcMessage message) => message switch + { + JsonRpcResponse response => McpServerResponseJsonContext.Default.JsonRpcResponse, + JsonRpcRequest request => McpServerRequestJsonContext.Default.JsonRpcRequest, + JsonRpcNotification notification => McpServerRequestJsonContext.Default.JsonRpcNotification, + _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), + }; } From 57c1d0b9e85f713bf79e278369002f77a748ab4c Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 3 Apr 2026 16:27:16 +0800 Subject: [PATCH 02/77] =?UTF-8?q?=E7=AE=80=E5=8C=96=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E9=87=87=E6=A0=B7=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E7=9A=84=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TouchSocketHttpServerTransport.cs | 159 +++++++++---- .../Clients/McpClient.cs | 26 +-- .../CompilerServices/McpJsonContext.cs | 50 ++-- .../Exceptions/McpExceptionData.cs | 6 +- ...McpServiceCollectionTransportExtensions.cs | 27 +++ .../Servers/IMcpServerPrimitiveContext.cs | 9 +- .../Servers/McpProtocolBridge.cs | 18 +- .../Servers/McpServerSampling.cs | 4 +- .../Transports/ClientTransportManager.cs | 24 +- .../Transports/Http/HttpClientTransport.cs | 4 +- .../Http/LocalHostHttpServerTransport.cs | 216 ++++++++---------- .../Transports/IServerTransportManager.cs | 10 + .../Transports/ServerTransportManager.cs | 20 +- .../Transports/Stdio/StdioClientTransport.cs | 4 +- .../Transports/Stdio/StdioServerTransport.cs | 4 +- .../Stdio/StdioServerTransportSession.cs | 8 +- .../McpTools/SamplingTool.cs | 33 +++ .../Servers/SamplingTests.cs | 81 +++++++ .../TestMcpFactory.cs | 12 +- 19 files changed, 446 insertions(+), 269 deletions(-) create mode 100644 src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs create mode 100644 tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs create mode 100644 tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index db203de..fb7465a 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -6,6 +6,7 @@ using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; using DotNetCampus.ModelContextProtocol.Servers; +using DotNetCampus.ModelContextProtocol.Transports; using DotNetCampus.ModelContextProtocol.Transports.Http; using TouchSocket.Core; using TouchSocket.Http; @@ -257,11 +258,15 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca } } - JsonRpcRequest? jsonRpcRequest; + var sessionIdStr = request.Headers.Get(SessionIdHeader).First; + + // 将 body 读取并解析为 JsonDocument,通过 JsonElement 检测消息类型。 + ReadOnlyMemory bodyBytes; + JsonDocument bodyDoc; try { - var bodyBytes = await request.GetContentAsync(); - jsonRpcRequest = await _manager.ReadRequestAsync(bodyBytes); + bodyBytes = await request.GetContentAsync(); + bodyDoc = JsonDocument.Parse(bodyBytes); } catch (JsonException) { @@ -270,71 +275,125 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca return; } - if (jsonRpcRequest == null) + using (bodyDoc) { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Empty body."); - await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); - return; - } + var bodyElement = bodyDoc.RootElement; - var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; - var sessionIdStr = request.Headers.Get(SessionIdHeader).First; - TouchSocketHttpServerTransportSession? session; + // 检测是 JSON-RPC 请求(有 method)还是响应(无 method,有 result 或 error)。 + var isResponse = !bodyElement.TryGetProperty("method", out _) + && (bodyElement.TryGetProperty("result", out _) || bodyElement.TryGetProperty("error", out _)); - if (isInitialize) - { - // 初始化请求,创建新 Session - var newSessionId = _manager.MakeNewSessionId(); - var newSession = new TouchSocketHttpServerTransportSession(_manager, newSessionId.Id); + if (isResponse) + { + // 客户端响应服务器发起的请求(如 sampling/createMessage)。 + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) + { + Log.Warn($"[McpServer][TouchSocket] Response routing failed: Session not found. SessionId={sessionIdStr}"); + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + JsonRpcResponse? jsonRpcResponse; + try + { + jsonRpcResponse = await _manager.ReadResponseAsync(bodyBytes); + } + catch (JsonException) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON response"); + return; + } + + if (jsonRpcResponse is not null) + { + responseSession.HandleResponseAsync(jsonRpcResponse); + } + + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + return; + } - if (_sessions.TryAdd(newSessionId.Id, newSession)) + JsonRpcRequest? jsonRpcRequest; + try { - session = newSession; - _manager.Add(session); - context.Response.Headers.Add(SessionIdHeader, newSessionId.Id); - Log.Info($"[McpServer][TouchSocket] Session created. SessionId={newSessionId.Id}"); + jsonRpcRequest = await _manager.ReadRequestAsync(bodyBytes); } - else + catch (JsonException) { - Log.Error($"[McpServer][TouchSocket] Session ID collision. SessionId={newSessionId.Id}"); - await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Invalid JSON."); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); return; } - } - else - { - if (string.IsNullOrEmpty(sessionIdStr)) + + if (jsonRpcRequest == null) { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header. Method={jsonRpcRequest.Method}"); - await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Empty body."); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); return; } - if (!_sessions.TryGetValue(sessionIdStr, out session)) + var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; + TouchSocketHttpServerTransportSession? session; + + if (isInitialize) { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}, Method={jsonRpcRequest.Method}"); - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; + // 初始化请求,创建新 Session + var newSessionId = _manager.MakeNewSessionId(); + var newSession = new TouchSocketHttpServerTransportSession(_manager, newSessionId.Id); + + if (_sessions.TryAdd(newSessionId.Id, newSession)) + { + session = newSession; + _manager.Add(session); + context.Response.Headers.Add(SessionIdHeader, newSessionId.Id); + Log.Info($"[McpServer][TouchSocket] Session created. SessionId={newSessionId.Id}"); + } + else + { + Log.Error($"[McpServer][TouchSocket] Session ID collision. SessionId={newSessionId.Id}"); + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return; + } + } + else + { + if (string.IsNullOrEmpty(sessionIdStr)) + { + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header. Method={jsonRpcRequest.Method}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); + return; + } + + if (!_sessions.TryGetValue(sessionIdStr, out session)) + { + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}, Method={jsonRpcRequest.Method}"); + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } } - } - Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - var jsonRpcResponse = await _manager.HandleRequestAsync(jsonRpcRequest, - s => s.AddHttpTransportServices(session.SessionId, request), - cancellationToken: cancellationToken); + var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session); + }, + cancellationToken: cancellationToken); - if (jsonRpcResponse != null) - { - // Request: Success or Failed. - Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, jsonRpcResponse); - } - else - { - // Notification: No need to respond. - Log.Debug($"[McpServer][TouchSocket] No response for notification. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await context.RespondHttpSuccess(HttpStatusCode.Accepted); + if (jsonRpcResponse2 != null) + { + // Request: Success or Failed. + Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, jsonRpcResponse2); + } + else + { + // Notification: No need to respond. + Log.Debug($"[McpServer][TouchSocket] No response for notification. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } } } diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs index 76c3b04..5e87a90 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization.Metadata; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Exceptions; @@ -115,12 +115,12 @@ public async Task ListToolsAsync(string? cursor = null, Cancell : JsonSerializer.SerializeToElement(new ListToolsRequestParams { Cursor = cursor, - }, McpServerRequestJsonContext.Default.ListToolsRequestParams), + }, McpInternalJsonContext.Default.ListToolsRequestParams), }; var response = await Transport.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); response.ThrowClientExceptionIfError(); - return DeserializeResult(response, McpServerResponseJsonContext.Default.ListToolsResult); + return DeserializeResult(response, McpInternalJsonContext.Default.ListToolsResult); } /// @@ -142,12 +142,12 @@ public async Task CallToolAsync(string toolName, JsonElement? ar { Name = toolName, Arguments = arguments, - }, McpServerRequestJsonContext.Default.CallToolRequestParams), + }, McpInternalJsonContext.Default.CallToolRequestParams), }; var response = await Transport.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); response.ThrowClientExceptionIfError(); - return DeserializeResult(response, McpServerResponseJsonContext.Default.CallToolResult); + return DeserializeResult(response, McpInternalJsonContext.Default.CallToolResult); } /// @@ -169,12 +169,12 @@ public async Task ListResourcesAsync(string? cursor = null, : JsonSerializer.SerializeToElement(new ListResourcesRequestParams { Cursor = cursor, - }, McpServerRequestJsonContext.Default.ListResourcesRequestParams), + }, McpInternalJsonContext.Default.ListResourcesRequestParams), }; var response = await Transport.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); response.ThrowClientExceptionIfError(); - return DeserializeResult(response, McpServerResponseJsonContext.Default.ListResourcesResult); + return DeserializeResult(response, McpInternalJsonContext.Default.ListResourcesResult); } /// @@ -194,12 +194,12 @@ public async Task ReadResourceAsync(string uri, Cancellation Params = JsonSerializer.SerializeToElement(new ReadResourceRequestParams { Uri = uri, - }, McpServerRequestJsonContext.Default.ReadResourceRequestParams), + }, McpInternalJsonContext.Default.ReadResourceRequestParams), }; var response = await Transport.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); response.ThrowClientExceptionIfError(); - return DeserializeResult(response, McpServerResponseJsonContext.Default.ReadResourceResult); + return DeserializeResult(response, McpInternalJsonContext.Default.ReadResourceResult); } /// @@ -221,12 +221,12 @@ public async Task ListPromptsAsync(string? cursor = null, Can : JsonSerializer.SerializeToElement(new ListPromptsRequestParams { Cursor = cursor, - }, McpServerRequestJsonContext.Default.ListPromptsRequestParams), + }, McpInternalJsonContext.Default.ListPromptsRequestParams), }; var response = await Transport.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); response.ThrowClientExceptionIfError(); - return DeserializeResult(response, McpServerResponseJsonContext.Default.ListPromptsResult); + return DeserializeResult(response, McpInternalJsonContext.Default.ListPromptsResult); } /// @@ -248,12 +248,12 @@ public async Task GetPromptAsync(string name, Dictionary(response, McpServerResponseJsonContext.Default.GetPromptResult); + return DeserializeResult(response, McpInternalJsonContext.Default.GetPromptResult); } /// diff --git a/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs b/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs index cf495fc..e507447 100644 --- a/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs @@ -112,67 +112,53 @@ public partial class CompiledSchemaJsonContext : JsonSerializerContext; internal partial class McpServerToolJsonContext : JsonSerializerContext; /// -/// 提供给 MCP 协议中,服务端收到来自客户端的请求数据时使用的 JSON 序列化上下文。 -/// -[JsonSerializable(typeof(CallToolRequestParams))] -[JsonSerializable(typeof(CreateMessageRequestParams))] -[JsonSerializable(typeof(GetPromptRequestParams))] -[JsonSerializable(typeof(InitializeRequestParams))] -[JsonSerializable(typeof(JsonElement))] -[JsonSerializable(typeof(ListPromptsRequestParams))] -[JsonSerializable(typeof(JsonRpcNotification))] -[JsonSerializable(typeof(JsonRpcRequest))] -[JsonSerializable(typeof(JsonRpcResponse))] -[JsonSerializable(typeof(ListResourcesRequestParams))] -[JsonSerializable(typeof(ListResourceTemplatesRequestParams))] -[JsonSerializable(typeof(ListToolsRequestParams))] -[JsonSerializable(typeof(LoggingLevel))] -[JsonSerializable(typeof(ModelHint))] -[JsonSerializable(typeof(ModelPreferences))] -[JsonSerializable(typeof(PingRequestParams))] -[JsonSerializable(typeof(ReadResourceRequestParams))] -[JsonSerializable(typeof(SamplingMessage))] -[JsonSerializable(typeof(SetLevelRequestParams))] -[JsonSerializable(typeof(ToolChoice))] -[JsonSourceGenerationOptions( - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - UseStringEnumConverter = true, - WriteIndented = false)] -internal partial class McpServerRequestJsonContext : JsonSerializerContext; - -/// -/// 提供给 MCP 协议中,服务端发送给客户端的响应数据时使用的 JSON 序列化上下文。 +/// MCP 协议内部使用的统一 JSON 序列化上下文,涵盖所有请求参数类型和响应结果类型。 /// [JsonSerializable(typeof(Annotations))] [JsonSerializable(typeof(AudioContentBlock))] [JsonSerializable(typeof(BlobResourceContents))] +[JsonSerializable(typeof(CallToolRequestParams))] [JsonSerializable(typeof(CallToolResult))] [JsonSerializable(typeof(CompiledJsonSchema))] [JsonSerializable(typeof(ContentBlock))] +[JsonSerializable(typeof(CreateMessageRequestParams))] [JsonSerializable(typeof(CreateMessageResult))] [JsonSerializable(typeof(EmbeddedResourceContentBlock))] [JsonSerializable(typeof(EmptyObject))] +[JsonSerializable(typeof(GetPromptRequestParams))] [JsonSerializable(typeof(GetPromptResult))] [JsonSerializable(typeof(ImageContentBlock))] +[JsonSerializable(typeof(InitializeRequestParams))] [JsonSerializable(typeof(InitializeResult))] [JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(JsonRpcNotification))] [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcResponse))] +[JsonSerializable(typeof(ListPromptsRequestParams))] [JsonSerializable(typeof(ListPromptsResult))] +[JsonSerializable(typeof(ListResourcesRequestParams))] [JsonSerializable(typeof(ListResourcesResult))] +[JsonSerializable(typeof(ListResourceTemplatesRequestParams))] [JsonSerializable(typeof(ListResourceTemplatesResult))] +[JsonSerializable(typeof(ListToolsRequestParams))] [JsonSerializable(typeof(ListToolsResult))] +[JsonSerializable(typeof(LoggingLevel))] [JsonSerializable(typeof(McpExceptionData))] +[JsonSerializable(typeof(ModelHint))] +[JsonSerializable(typeof(ModelPreferences))] +[JsonSerializable(typeof(PingRequestParams))] +[JsonSerializable(typeof(ReadResourceRequestParams))] [JsonSerializable(typeof(ReadResourceResult))] [JsonSerializable(typeof(ResourceContents))] [JsonSerializable(typeof(ResourceLinkContentBlock))] [JsonSerializable(typeof(SamplingMessage))] +[JsonSerializable(typeof(SetLevelRequestParams))] [JsonSerializable(typeof(TextContentBlock))] [JsonSerializable(typeof(TextResourceContents))] +[JsonSerializable(typeof(ToolChoice))] [JsonSourceGenerationOptions( PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true, WriteIndented = false)] -internal partial class McpServerResponseJsonContext : JsonSerializerContext; +internal partial class McpInternalJsonContext : JsonSerializerContext; diff --git a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpExceptionData.cs b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpExceptionData.cs index 9d237fa..5ff2c6c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpExceptionData.cs +++ b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpExceptionData.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using DotNetCampus.ModelContextProtocol.CompilerServices; @@ -33,7 +33,7 @@ public record McpExceptionData /// 表示当前实例的 public JsonElement ToJsonElement() { - return JsonSerializer.SerializeToElement(this, McpServerResponseJsonContext.Default.McpExceptionData); + return JsonSerializer.SerializeToElement(this, McpInternalJsonContext.Default.McpExceptionData); } /// @@ -42,7 +42,7 @@ public JsonElement ToJsonElement() /// 表示当前实例的 JSON 字符串。 public string ToJsonString() { - return JsonSerializer.Serialize(this, McpServerResponseJsonContext.Default.McpExceptionData); + return JsonSerializer.Serialize(this, McpInternalJsonContext.Default.McpExceptionData); } /// diff --git a/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs b/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs new file mode 100644 index 0000000..1a7dc51 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs @@ -0,0 +1,27 @@ +using DotNetCampus.ModelContextProtocol.Servers; +using DotNetCampus.ModelContextProtocol.Transports; + +namespace DotNetCampus.ModelContextProtocol.Hosting.Services; + +/// +/// 提供向 注册传输层会话服务的扩展方法。
+/// Extension methods for registering transport session services into . +///
+public static class McpServiceCollectionTransportExtensions +{ + /// + /// 向 MCP 服务集合中注册传输层会话相关服务,包括 + ///
+ /// Registers transport session services into the MCP service collection, + /// including and . + ///
+ /// MCP 服务集合。The MCP service collection. + /// 当前传输层会话实例。The current transport session instance. + /// 提供链式调用的服务集合。The service collection for chaining. + public static IMcpServiceCollection AddTransportSession(this IMcpServiceCollection services, IServerTransportSession session) + { + services.AddScoped(session); + services.AddScoped(new McpServerSampling(session)); + return services; + } +} diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs index d591dcc..0a4dfdd 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs @@ -63,11 +63,11 @@ public interface IMcpServerCallToolContext : IMcpServerPrimitiveContext CancellationToken CancellationToken { get; } /// - /// 提供服务器向客户端发起 Sampling 请求的能力。如果当前传输层或客户端不支持 Sampling,调用相关方法将抛出异常。
+ /// 提供服务器向客户端发起 Sampling 请求的能力。当传输层或客户端不支持 Sampling 时返回
/// Provides the ability to send Sampling requests from the server to the client. - /// If the current transport or client does not support Sampling, calling the related methods will throw an exception. + /// Returns when the transport or client does not support Sampling. ///
- IMcpServerSampling Sampling { get; } + IMcpServerSampling? Sampling { get; } } /// @@ -100,8 +100,7 @@ internal sealed class McpServerCallToolContext : IMcpServerCallToolContext public required string Name { get; init; } public required JsonElement InputJsonArguments { get; init; } public required CancellationToken CancellationToken { get; init; } - public IMcpServerSampling Sampling => (IMcpServerSampling?)Services.GetService(typeof(IMcpServerSampling)) - ?? throw new InvalidOperationException("当前传输层未提供 IMcpServerSampling 服务,或客户端未声明 Sampling 能力。The current transport has not provided IMcpServerSampling, or the client has not declared Sampling capability."); + public IMcpServerSampling? Sampling => (IMcpServerSampling?)Services.GetService(typeof(IMcpServerSampling)); } internal sealed class McpServerReadResourceContext : IMcpServerReadResourceContext diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs index 19c4a04..24e6eea 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using DotNetCampus.ModelContextProtocol.CompilerServices; @@ -122,28 +122,28 @@ private async ValueTask HandleRequestCoreAsync( }, }, Initialize => await HandleRequestAsync(request, services, context.Handlers.HandleInitializeAsync, - McpServerRequestJsonContext.Default.InitializeRequestParams, McpServerResponseJsonContext.Default.InitializeResult, + McpInternalJsonContext.Default.InitializeRequestParams, McpInternalJsonContext.Default.InitializeResult, cancellationToken), Ping => await HandleRequestAsync(request, services, context.Handlers.HandlePingAsync, - McpServerRequestJsonContext.Default.PingRequestParams, McpServerResponseJsonContext.Default.EmptyObject, + McpInternalJsonContext.Default.PingRequestParams, McpInternalJsonContext.Default.EmptyObject, cancellationToken), LoggingSetLevel => await HandleRequestAsync(request, services, context.Handlers.HandleSetLoggingLevelAsync, - McpServerRequestJsonContext.Default.SetLevelRequestParams, McpServerResponseJsonContext.Default.EmptyObject, + McpInternalJsonContext.Default.SetLevelRequestParams, McpInternalJsonContext.Default.EmptyObject, cancellationToken), ToolsList => await HandleRequestAsync(request, services, context.Handlers.HandleListToolsAsync, - McpServerRequestJsonContext.Default.ListToolsRequestParams, McpServerResponseJsonContext.Default.ListToolsResult, + McpInternalJsonContext.Default.ListToolsRequestParams, McpInternalJsonContext.Default.ListToolsResult, cancellationToken), ToolsCall => await HandleRequestAsync(request, services, context.Handlers.HandleCallToolAsync, - McpServerRequestJsonContext.Default.CallToolRequestParams, McpServerResponseJsonContext.Default.CallToolResult, + McpInternalJsonContext.Default.CallToolRequestParams, McpInternalJsonContext.Default.CallToolResult, cancellationToken), ResourcesList => await HandleRequestAsync(request, services, context.Handlers.HandleListResourcesAsync, - McpServerRequestJsonContext.Default.ListResourcesRequestParams, McpServerResponseJsonContext.Default.ListResourcesResult, + McpInternalJsonContext.Default.ListResourcesRequestParams, McpInternalJsonContext.Default.ListResourcesResult, cancellationToken), ResourcesTemplatesList => await HandleRequestAsync(request, services, context.Handlers.HandleListResourceTemplatesAsync, - McpServerRequestJsonContext.Default.ListResourceTemplatesRequestParams, McpServerResponseJsonContext.Default.ListResourceTemplatesResult, + McpInternalJsonContext.Default.ListResourceTemplatesRequestParams, McpInternalJsonContext.Default.ListResourceTemplatesResult, cancellationToken), ResourcesRead => await HandleRequestAsync(request, services, context.Handlers.HandleReadResourceAsync, - McpServerRequestJsonContext.Default.ReadResourceRequestParams, McpServerResponseJsonContext.Default.ReadResourceResult, + McpInternalJsonContext.Default.ReadResourceRequestParams, McpInternalJsonContext.Default.ReadResourceResult, cancellationToken), _ => new JsonRpcResponse { diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs index 42966f2..c810677 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs @@ -94,7 +94,7 @@ public async Task CreateMessageAsync( { Id = RequestId.MakeNew().ToJsonElement(), Method = RequestMethods.SamplingCreateMessage, - Params = JsonSerializer.SerializeToElement(requestParams, McpServerRequestJsonContext.Default.CreateMessageRequestParams), + Params = JsonSerializer.SerializeToElement(requestParams, McpInternalJsonContext.Default.CreateMessageRequestParams), }; var response = await session.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); @@ -109,7 +109,7 @@ public async Task CreateMessageAsync( throw new McpClientException("Sampling response missing result."); } - return resultElement.Deserialize(McpServerResponseJsonContext.Default.CreateMessageResult) + return resultElement.Deserialize(McpInternalJsonContext.Default.CreateMessageResult) ?? throw new McpClientException("Failed to deserialize sampling result."); } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index b140236..110678c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -47,23 +47,23 @@ public RequestId MakeNewRequestId() /// public ValueTask ReadResponseAsync(string responseLine) { - var message = JsonSerializer.Deserialize(responseLine, McpServerResponseJsonContext.Default.JsonRpcResponse); + var message = JsonSerializer.Deserialize(responseLine, McpInternalJsonContext.Default.JsonRpcResponse); return ValueTask.FromResult(message); } /// public ValueTask ReadResponseAsync(Stream responseStream) { - var message = JsonSerializer.Deserialize(responseStream, McpServerResponseJsonContext.Default.JsonRpcResponse); + var message = JsonSerializer.Deserialize(responseStream, McpInternalJsonContext.Default.JsonRpcResponse); return ValueTask.FromResult(message); } /// public string WriteMessageAsync(JsonRpcMessage message) => message switch { - JsonRpcRequest request => JsonSerializer.Serialize(request, McpServerRequestJsonContext.Default.JsonRpcRequest), - JsonRpcResponse response => JsonSerializer.Serialize(response, McpServerResponseJsonContext.Default.JsonRpcResponse), - JsonRpcNotification notification => JsonSerializer.Serialize(notification, McpServerRequestJsonContext.Default.JsonRpcNotification), + JsonRpcRequest request => JsonSerializer.Serialize(request, McpInternalJsonContext.Default.JsonRpcRequest), + JsonRpcResponse response => JsonSerializer.Serialize(response, McpInternalJsonContext.Default.JsonRpcResponse), + JsonRpcNotification notification => JsonSerializer.Serialize(notification, McpInternalJsonContext.Default.JsonRpcNotification), _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), }; @@ -73,11 +73,11 @@ public async ValueTask WriteMessageAsync(Stream requestStream, JsonRpcMessage me await (message switch { JsonRpcRequest request => JsonSerializer.SerializeAsync( - requestStream, request, McpServerRequestJsonContext.Default.JsonRpcRequest, cancellationToken), + requestStream, request, McpInternalJsonContext.Default.JsonRpcRequest, cancellationToken), JsonRpcResponse response => JsonSerializer.SerializeAsync( - requestStream, response, McpServerResponseJsonContext.Default.JsonRpcResponse, cancellationToken), + requestStream, response, McpInternalJsonContext.Default.JsonRpcResponse, cancellationToken), JsonRpcNotification notification => JsonSerializer.SerializeAsync( - requestStream, notification, McpServerRequestJsonContext.Default.JsonRpcNotification, cancellationToken), + requestStream, notification, McpInternalJsonContext.Default.JsonRpcNotification, cancellationToken), _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), }); } @@ -111,7 +111,7 @@ public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, Cancella CreateMessageRequestParams? requestParams = null; if (request.Params is { } paramsElement) { - requestParams = paramsElement.Deserialize(McpServerRequestJsonContext.Default.CreateMessageRequestParams); + requestParams = paramsElement.Deserialize(McpInternalJsonContext.Default.CreateMessageRequestParams); } requestParams ??= new CreateMessageRequestParams { Messages = [], MaxTokens = 1024 }; @@ -119,7 +119,7 @@ public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, Cancella response = new JsonRpcResponse { Id = request.Id, - Result = JsonSerializer.SerializeToElement(result, McpServerResponseJsonContext.Default.CreateMessageResult), + Result = JsonSerializer.SerializeToElement(result, McpInternalJsonContext.Default.CreateMessageResult), }; } catch (Exception ex) @@ -221,7 +221,7 @@ public async ValueTask ConnectAndInitializeAsync(McpClient cli Version = client.ClientVersion, }, Capabilities = client.Capabilities, - }, McpServerRequestJsonContext.Default.InitializeRequestParams), + }, McpInternalJsonContext.Default.InitializeRequestParams), }; var response = await SendRequestAsync(request, cancellationToken).ConfigureAwait(false); @@ -236,7 +236,7 @@ public async ValueTask ConnectAndInitializeAsync(McpClient cli throw new McpClientException("初始化响应格式不正确"); } - var result = responseResult.Deserialize(McpServerResponseJsonContext.Default.InitializeResult) + var result = responseResult.Deserialize(McpInternalJsonContext.Default.InitializeResult) ?? throw new McpClientException("无法解析初始化响应"); // 发送 initialized 通知。 diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index c161ecb..8667d89 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using DotNetCampus.ModelContextProtocol.Hosting.Logging; @@ -435,7 +435,7 @@ private async Task DispatchSseEventAsync(string? eventName, string data, Cancell { try { - return JsonSerializer.Deserialize(json, CompilerServices.McpServerRequestJsonContext.Default.JsonRpcRequest); + return JsonSerializer.Deserialize(json, CompilerServices.McpInternalJsonContext.Default.JsonRpcRequest); } catch { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 5ca1e50..874058e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -1,12 +1,12 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Net; using System.Text; using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; -using DotNetCampus.ModelContextProtocol.Servers; namespace DotNetCampus.ModelContextProtocol.Transports.Http; @@ -186,13 +186,16 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat } } - // 读取 body 到内存,以便检查是请求还是响应。 - byte[] bodyBytes; + // 将 body 解析为 JsonDocument,一次解析后通过 JsonElement 检测消息类型,再按需反序列化。 + JsonDocument bodyDoc; try { - using var ms = new MemoryStream(); - await request.InputStream.CopyToAsync(ms, cancellationToken); - bodyBytes = ms.ToArray(); + bodyDoc = await JsonDocument.ParseAsync(request.InputStream, cancellationToken: cancellationToken); + } + catch (JsonException) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); + return; } catch { @@ -200,152 +203,123 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat return; } - if (bodyBytes.Length == 0) + using (bodyDoc) { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); - return; - } + var bodyElement = bodyDoc.RootElement; - // 检测是 JSON-RPC 请求(有 method)还是响应(无 method)。 - var isResponse = IsJsonRpcResponseBytes(bodyBytes); + // 检测是 JSON-RPC 请求(有 method)还是响应(无 method,有 result 或 error)。 + var isResponse = !bodyElement.TryGetProperty("method", out _) + && (bodyElement.TryGetProperty("result", out _) || bodyElement.TryGetProperty("error", out _)); - var sessionIdStr = request.Headers[SessionIdHeader]; + var sessionIdStr = request.Headers[SessionIdHeader]; - if (isResponse) - { - // 客户端响应服务器发起的请求(如 sampling/createMessage)。 - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) + if (isResponse) { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + // 客户端响应服务器发起的请求(如 sampling/createMessage)。 + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + JsonRpcResponse? jsonRpcResponse; + try + { + jsonRpcResponse = bodyElement.Deserialize(McpInternalJsonContext.Default.JsonRpcResponse); + } + catch (JsonException) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON response"); + return; + } + + if (jsonRpcResponse is not null) + { + responseSession.HandleResponseAsync(jsonRpcResponse); + } + + context.RespondHttpSuccess(HttpStatusCode.Accepted); return; } - JsonRpcResponse? jsonRpcResponse; + JsonRpcRequest? jsonRpcRequest; try { - jsonRpcResponse = JsonSerializer.Deserialize(bodyBytes, McpServerResponseJsonContext.Default.JsonRpcResponse); + jsonRpcRequest = bodyElement.Deserialize(McpInternalJsonContext.Default.JsonRpcRequest); } catch (JsonException) { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON response"); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); return; } - if (jsonRpcResponse is not null) + if (jsonRpcRequest == null) { - responseSession.HandleResponseAsync(jsonRpcResponse); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); + return; } - context.RespondHttpSuccess(HttpStatusCode.Accepted); - return; - } - - JsonRpcRequest? jsonRpcRequest; - try - { - jsonRpcRequest = JsonSerializer.Deserialize(bodyBytes, McpServerRequestJsonContext.Default.JsonRpcRequest); - } - catch (JsonException) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); - return; - } + var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; + LocalHostHttpServerTransportSession? session; - if (jsonRpcRequest == null) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); - return; - } - - var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; - LocalHostHttpServerTransportSession? session; - - if (isInitialize) - { - // 初始化请求,创建新 Session - var newSessionId = _manager.MakeNewSessionId(); - var newSession = new LocalHostHttpServerTransportSession(_manager, newSessionId.Id); - - if (_sessions.TryAdd(newSessionId.Id, newSession)) + if (isInitialize) { - session = newSession; - _manager.Add(session); - context.Response.AppendHeader(SessionIdHeader, newSessionId.Id); + // 初始化请求,创建新 Session + var newSessionId = _manager.MakeNewSessionId(); + var newSession = new LocalHostHttpServerTransportSession(_manager, newSessionId.Id); + + if (_sessions.TryAdd(newSessionId.Id, newSession)) + { + session = newSession; + _manager.Add(session); + context.Response.AppendHeader(SessionIdHeader, newSessionId.Id); + } + else + { + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return; + } } else { - await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); - return; - } - } - else - { - if (string.IsNullOrEmpty(sessionIdStr)) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); - return; - } + if (string.IsNullOrEmpty(sessionIdStr)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); + return; + } - if (!_sessions.TryGetValue(sessionIdStr, out session)) - { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; + if (!_sessions.TryGetValue(sessionIdStr, out session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } } - } - var capturedSession = session; - var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, - s => - { - s.AddScoped(capturedSession); - s.AddScoped(new McpServerSampling(capturedSession)); - }, - cancellationToken); + var capturedSession = session; + var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, + s => s.AddTransportSession(capturedSession), + cancellationToken); - if (jsonRpcResponse2 != null) - { - // Request: Success or Failed. - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)HttpStatusCode.OK; - try - { - await _manager.WriteMessageAsync(context.Response.OutputStream, jsonRpcResponse2, cancellationToken); - context.Response.SafeClose(); - } - catch + if (jsonRpcResponse2 != null) { - // Ignore write errors - } - } - else - { - // Notification: No need to respond. - context.RespondHttpSuccess(HttpStatusCode.Accepted); - } - } - - private static bool IsJsonRpcResponseBytes(byte[] bodyBytes) - { - try - { - var reader = new Utf8JsonReader(bodyBytes); - if (!JsonDocument.TryParseValue(ref reader, out var doc)) - { - return false; - } - using (doc) - { - var root = doc.RootElement; - if (root.TryGetProperty("method", out _)) + // Request: Success or Failed. + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.OK; + try { - return false; + await _manager.WriteMessageAsync(context.Response.OutputStream, jsonRpcResponse2, cancellationToken); + context.Response.SafeClose(); + } + catch + { + // Ignore write errors } - return root.TryGetProperty("result", out _) || root.TryGetProperty("error", out _); } - } - catch - { - return false; + else + { + // Notification: No need to respond. + context.RespondHttpSuccess(HttpStatusCode.Accepted); + } } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs index ff77c29..edd8d0b 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs @@ -76,6 +76,16 @@ public interface IServerTransportManager /// ValueTask ReadRequestAsync(ReadOnlyMemory requestMemory); + /// + /// 提供给传输层调用。当传输层收到 JSON-RPC 响应(无 method 字段、有 result 或 error 字段)后, + /// 调用此方法可以将响应字节解析为 对象。
+ /// Available for transport implementations. Parses a raw JSON-RPC response from memory + /// into a object. + ///
+ /// JSON-RPC 响应的原始字节。Raw bytes of the JSON-RPC response. + /// 解析出来的 JSON-RPC 响应对象,如果无法解析则返回 + ValueTask ReadResponseAsync(ReadOnlyMemory responseMemory); + /// /// 提供给传输层调用,用于发送消息给 MCP 客户端。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index c82c387..be3b0b6 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -1,4 +1,4 @@ -using System.Buffers; +using System.Buffers; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.IO.Pipelines; @@ -137,7 +137,7 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio public ValueTask ReadRequestAsync(string requestLine) { - var message = JsonSerializer.Deserialize(requestLine, McpServerRequestJsonContext.Default.JsonRpcRequest); + var message = JsonSerializer.Deserialize(requestLine, McpInternalJsonContext.Default.JsonRpcRequest); if (message is { Method: RequestMethods.Initialize, Id: null }) { return ValueTask.FromResult(message with { Id = MakeNewSessionId().ToJsonElement() }); @@ -147,7 +147,7 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio public async ValueTask ReadRequestAsync(Stream requestStream) { - var message = await JsonSerializer.DeserializeAsync(requestStream, McpServerRequestJsonContext.Default.JsonRpcRequest); + var message = await JsonSerializer.DeserializeAsync(requestStream, McpInternalJsonContext.Default.JsonRpcRequest); if (message is { Method: RequestMethods.Initialize, Id: null }) { return message with { Id = MakeNewSessionId().ToJsonElement() }; @@ -158,7 +158,7 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio public async ValueTask ReadRequestAsync(ReadOnlyMemory requestMemory) { var pipeReader = PipeReader.Create(new ReadOnlySequence(requestMemory)); - var message = await JsonSerializer.DeserializeAsync(pipeReader, McpServerRequestJsonContext.Default.JsonRpcRequest); + var message = await JsonSerializer.DeserializeAsync(pipeReader, McpInternalJsonContext.Default.JsonRpcRequest); if (message is { Method: RequestMethods.Initialize, Id: null }) { return message with { Id = MakeNewSessionId().ToJsonElement() }; @@ -166,14 +166,20 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio return message; } + public async ValueTask ReadResponseAsync(ReadOnlyMemory responseMemory) + { + var pipeReader = PipeReader.Create(new ReadOnlySequence(responseMemory)); + return await JsonSerializer.DeserializeAsync(pipeReader, McpInternalJsonContext.Default.JsonRpcResponse); + } + public Task WriteMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken cancellationToken) => message switch { JsonRpcResponse response => JsonSerializer.SerializeAsync(stream, response, - McpServerResponseJsonContext.Default.JsonRpcResponse, cancellationToken), + McpInternalJsonContext.Default.JsonRpcResponse, cancellationToken), JsonRpcRequest request => JsonSerializer.SerializeAsync(stream, request, - McpServerRequestJsonContext.Default.JsonRpcRequest, cancellationToken), + McpInternalJsonContext.Default.JsonRpcRequest, cancellationToken), JsonRpcNotification notification => JsonSerializer.SerializeAsync(stream, notification, - McpServerRequestJsonContext.Default.JsonRpcNotification, cancellationToken), + McpInternalJsonContext.Default.JsonRpcNotification, cancellationToken), _ => throw new InvalidOperationException($"Unsupported message type: {message.GetType().FullName}"), }; diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs index 8cccb44..0edac7c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Diagnostics.Contracts; using System.Text; using System.Text.Json; @@ -146,7 +146,7 @@ private static bool IsServerRequest(string json) { try { - return JsonSerializer.Deserialize(json, McpServerRequestJsonContext.Default.JsonRpcRequest); + return JsonSerializer.Deserialize(json, McpInternalJsonContext.Default.JsonRpcRequest); } catch { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs index 5aa1b4e..a87a578 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; @@ -165,7 +165,7 @@ private static bool IsJsonRpcResponse(string json) { try { - return JsonSerializer.Deserialize(json, McpServerResponseJsonContext.Default.JsonRpcResponse); + return JsonSerializer.Deserialize(json, McpInternalJsonContext.Default.JsonRpcResponse); } catch { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index a0fd23c..ba444f8 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; @@ -112,9 +112,9 @@ public ValueTask DisposeAsync() private static System.Text.Json.Serialization.Metadata.JsonTypeInfo GetTypeInfo(JsonRpcMessage message) => message switch { - JsonRpcResponse response => McpServerResponseJsonContext.Default.JsonRpcResponse, - JsonRpcRequest request => McpServerRequestJsonContext.Default.JsonRpcRequest, - JsonRpcNotification notification => McpServerRequestJsonContext.Default.JsonRpcNotification, + JsonRpcResponse response => McpInternalJsonContext.Default.JsonRpcResponse, + JsonRpcRequest request => McpInternalJsonContext.Default.JsonRpcRequest, + JsonRpcNotification notification => McpInternalJsonContext.Default.JsonRpcNotification, _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), }; } diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs new file mode 100644 index 0000000..b38d142 --- /dev/null +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs @@ -0,0 +1,33 @@ +using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Servers; + +namespace DotNetCampus.ModelContextProtocol.Tests.McpTools; + +/// +/// 用于测试服务器向客户端发起 Sampling 请求的工具。 +/// +public class SamplingTool +{ + /// + /// 通过服务端 Sampling 能力向客户端 LLM 发起采样请求,并返回结果文本。 + /// + [McpServerTool] + public async Task AskLlm(string message, IMcpServerCallToolContext context) + { + var sampling = context.Sampling + ?? throw new InvalidOperationException("Sampling service not available in this context."); + + var result = await sampling.CreateMessageAsync(message); + return result.Content is TextContentBlock textBlock ? textBlock.Text : string.Empty; + } + + /// + /// 检查客户端是否声明了 Sampling 能力(HasSamplingCapability)。 + /// + [McpServerTool] + public string CheckSamplingCapability(IMcpServerCallToolContext context) + { + return $"has_capability={context.Sampling?.HasSamplingCapability ?? false}"; + } +} diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs new file mode 100644 index 0000000..b1ab0d0 --- /dev/null +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Tests.McpTools; + +namespace DotNetCampus.ModelContextProtocol.Tests.Servers; + +/// +/// Sampling 功能集成测试:验证服务器向客户端发起 sampling/createMessage 请求的完整流程。 +/// +[TestClass] +public class SamplingTests +{ + #region Sampling 基本功能 + + [TestMethod("Sampling: 服务器工具可通过 context.Sampling 向客户端发起采样请求")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task ServerToolCanRequestSampling(HttpTransportType transportType) + { + // Arrange + const string expectedResponseText = "Hello from LLM!"; + var samplingHandlerInvoked = false; + + await using var package = await TestMcpFactory.Shared.CreateHttpCoreAsync( + transportType, + configureBuilder: builder => builder.WithTools(t => t.WithTool(() => new SamplingTool())), + configureClient: clientBuilder => clientBuilder.WithSamplingHandler( + (parms, ct) => + { + samplingHandlerInvoked = true; + var result = new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = expectedResponseText }, + Model = "test-model", + StopReason = "endTurn", + }; + return Task.FromResult(result); + })); + + // Act + var toolArgs = JsonSerializer.SerializeToElement(new { message = "What's 2+2?" }); + var callResult = await package.Client.CallToolAsync("ask_llm", toolArgs); + + // Assert + Assert.IsNotNull(callResult, "工具调用结果不应为 null"); + Assert.IsFalse(callResult.IsError, "工具调用不应返回错误"); + Assert.IsTrue(samplingHandlerInvoked, "客户端的 Sampling 处理器应被调用"); + + var textContent = callResult.Content.OfType().FirstOrDefault(); + Assert.IsNotNull(textContent, "工具调用结果应包含文本内容"); + Assert.AreEqual(expectedResponseText, textContent.Text, "工具返回的文本应与 Sampling 响应一致"); + } + + [TestMethod("Sampling: 无 Sampling 能力时 HasSamplingCapability 为 false")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task HasSamplingCapabilityIsFalseWhenClientHasNoCapability(HttpTransportType transportType) + { + // Arrange - 客户端不配置 WithSamplingHandler,因此不声明采样能力 + await using var package = await TestMcpFactory.Shared.CreateHttpCoreAsync( + transportType, + configureBuilder: builder => builder.WithTools(t => t.WithTool(() => new SamplingTool()))); + + // Act + var toolArgs = JsonSerializer.SerializeToElement(new { }); + var callResult = await package.Client.CallToolAsync("check_sampling_capability"); + + // Assert + Assert.IsNotNull(callResult, "工具调用结果不应为 null"); + Assert.IsFalse(callResult.IsError, "工具调用不应返回错误"); + + var textContent = callResult.Content.OfType().FirstOrDefault(); + Assert.IsNotNull(textContent, "工具调用结果应包含文本内容"); + Assert.AreEqual("has_capability=False", textContent.Text, + "当客户端未声明 Sampling 能力时,HasSamplingCapability 应为 false"); + } + + #endregion +} + diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs index dc12864..77b9284 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs @@ -144,11 +144,12 @@ public async ValueTask CreateHttpAsync( } /// - /// 核心方法:创建一个完全自定义的 HTTP 传输 MCP 测试包。 + /// 核心方法:创建一个完全自定义的 HTTP 传输 MCP 测试包,支持同时配置服务端和客户端。 /// public async ValueTask CreateHttpCoreAsync( HttpTransportType httpTransportType, - Action configureBuilder) + Action configureBuilder, + Action? configureClient = null) { var port = Interlocked.Increment(ref _port); var mcpServerBuilder = new McpServerBuilder("TestMcpServer", "1.0.0") @@ -176,10 +177,11 @@ public async ValueTask CreateHttpCoreAsync( var mcpClient = new McpClientBuilder() .WithLogger(DefaultLogger) - .WithHttp($"http://127.0.0.1:{port}/mcp") - .Build(); + .WithHttp($"http://127.0.0.1:{port}/mcp"); + configureClient?.Invoke(mcpClient); + var builtClient = mcpClient.Build(); - return new McpTestingPackage(mcpServer, mcpClient); + return new McpTestingPackage(mcpServer, builtClient); } private static IServiceProvider CreateDefaultServices() From c1f4f2f46949ccdb1fcb258b1a4bcef7cb97b448 Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 3 Apr 2026 16:35:47 +0800 Subject: [PATCH 03/77] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=9A=84=20MCP=20=E9=87=87=E6=A0=B7=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{SampleTool.cs => EchoDelayTool.cs} | 2 +- .../McpTools/SamplingTool.cs | 48 +++++++++++++++++++ .../DotNetCampus.SampleMcpServer/Program.cs | 3 +- 3 files changed, 51 insertions(+), 2 deletions(-) rename samples/DotNetCampus.SampleMcpServer/McpTools/{SampleTool.cs => EchoDelayTool.cs} (99%) create mode 100644 samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs diff --git a/samples/DotNetCampus.SampleMcpServer/McpTools/SampleTool.cs b/samples/DotNetCampus.SampleMcpServer/McpTools/EchoDelayTool.cs similarity index 99% rename from samples/DotNetCampus.SampleMcpServer/McpTools/SampleTool.cs rename to samples/DotNetCampus.SampleMcpServer/McpTools/EchoDelayTool.cs index 4394491..ebe0338 100644 --- a/samples/DotNetCampus.SampleMcpServer/McpTools/SampleTool.cs +++ b/samples/DotNetCampus.SampleMcpServer/McpTools/EchoDelayTool.cs @@ -3,7 +3,7 @@ namespace DotNetCampus.SampleMcpServer.McpTools; -public class SampleTool +public class EchoDelayTool { /// /// 用于给 AI 调试使用的工具,原样返回一些信息 diff --git a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs new file mode 100644 index 0000000..ba73a18 --- /dev/null +++ b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs @@ -0,0 +1,48 @@ +using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Servers; + +namespace DotNetCampus.SampleMcpServer.McpTools; + +public class SamplingTool +{ + /// + /// 通过客户端的 LLM 进行采样,将 prompt 发送给客户端,获取 LLM 响应并返回。 + /// 用于人工验证 sampling/createMessage 协议流程是否正常。 + /// + /// 发送给 LLM 的提示词 + /// 最大生成令牌数 + /// 可选的系统提示词 + /// MCP 工具上下文 + [McpServerTool] + public async Task AskLlm( + string prompt, + int maxTokens = 1024, + string? systemPrompt = null, + IMcpServerCallToolContext context = null!) + { + var sampling = context.Sampling; + if (sampling is null || !sampling.HasSamplingCapability) + { + return CallToolResult.FromError( + "当前客户端未声明 Sampling 能力。请确保客户端支持 sampling/createMessage 请求。\n" + + "The connected client has not declared Sampling capability."); + } + + var result = await sampling.CreateMessageAsync(prompt, maxTokens, systemPrompt, context.CancellationToken); + + var responseText = result.Content switch + { + TextContentBlock text => text.Text, + _ => $"[Non-text content: {result.Content?.GetType().Name}]", + }; + + return $""" + Model: {result.Model} + StopReason: {result.StopReason ?? "unknown"} + Role: {result.Role} + --- + {responseText} + """; + } +} diff --git a/samples/DotNetCampus.SampleMcpServer/Program.cs b/samples/DotNetCampus.SampleMcpServer/Program.cs index 23c4551..9d5875a 100644 --- a/samples/DotNetCampus.SampleMcpServer/Program.cs +++ b/samples/DotNetCampus.SampleMcpServer/Program.cs @@ -32,11 +32,12 @@ private static async Task Main(string[] args) .WithRequestHandlers(s => new CustomRequestHandlers(s)) .WithJsonSerializer(McpToolJsonContext.Default) .WithTools(t => t - .WithTool(() => new SampleTool()) + .WithTool(() => new EchoDelayTool()) .WithTool(() => new InputTool()) .WithTool(() => new OutputTool()) .WithTool(() => new PolymorphicTool()) .WithTool(() => new ResourceTool()) + .WithTool(() => new SamplingTool()) ) .WithResources(r => r .WithResource(() => new SampleResource()) From 27efe1b12479fe1c3441cea945f40c5b7612c7f7 Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 3 Apr 2026 17:53:57 +0800 Subject: [PATCH 04/77] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E9=87=87=E6=A0=B7?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E4=B8=8D=E8=A2=AB=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=AE=A4=E8=AF=86=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../McpTools/SamplingTool.cs | 39 ++++++++++++------- .../McpSamplingRejectedException.cs | 35 +++++++++++++++++ .../Protocol/Messages/Role.cs | 5 +-- .../Servers/IMcpServerPrimitiveContext.cs | 12 +++--- .../Servers/McpServerSampling.cs | 36 +++++++++++++++++ .../McpTools/SamplingTool.cs | 10 +++-- 6 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs diff --git a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs index ba73a18..4ac18b7 100644 --- a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs +++ b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs @@ -1,4 +1,5 @@ using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Exceptions; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Servers; @@ -21,28 +22,36 @@ public async Task AskLlm( string? systemPrompt = null, IMcpServerCallToolContext context = null!) { - var sampling = context.Sampling; - if (sampling is null || !sampling.HasSamplingCapability) + if (!context.Sampling.HasSamplingCapability) { return CallToolResult.FromError( "当前客户端未声明 Sampling 能力。请确保客户端支持 sampling/createMessage 请求。\n" + "The connected client has not declared Sampling capability."); } - var result = await sampling.CreateMessageAsync(prompt, maxTokens, systemPrompt, context.CancellationToken); - - var responseText = result.Content switch + try { - TextContentBlock text => text.Text, - _ => $"[Non-text content: {result.Content?.GetType().Name}]", - }; + var result = await context.Sampling.CreateMessageAsync(prompt, maxTokens, systemPrompt, context.CancellationToken); + + var responseText = result.Content switch + { + TextContentBlock text => text.Text, + _ => $"[Non-text content: {result.Content?.GetType().Name}]", + }; - return $""" - Model: {result.Model} - StopReason: {result.StopReason ?? "unknown"} - Role: {result.Role} - --- - {responseText} - """; + return $""" + Model: {result.Model} + StopReason: {result.StopReason ?? "unknown"} + Role: {result.Role} + --- + {responseText} + """; + } + catch (McpSamplingRejectedException ex) + { + return CallToolResult.FromError( + $"采样请求被用户拒绝。Sampling request was rejected by the user.\n" + + $"Code: {ex.ErrorCode}, Message: {ex.RejectionMessage}"); + } } } diff --git a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs new file mode 100644 index 0000000..f4d377a --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs @@ -0,0 +1,35 @@ +namespace DotNetCampus.ModelContextProtocol.Exceptions; + +/// +/// 当 MCP 客户端的采样请求被用户(人工审批)拒绝时引发的异常。
+/// 根据 MCP 规范,客户端实现应提供人工审批机制(human-in-the-loop),允许用户在采样请求发送给 LLM 之前拒绝它。
+/// Exception thrown when a sampling request was rejected by the user (human-in-the-loop approval was denied). +/// Per the MCP specification, client implementations SHOULD provide a human-in-the-loop mechanism +/// that allows users to deny sampling requests before they are sent to an LLM. +///
+public class McpSamplingRejectedException : McpClientException +{ + /// + /// 初始化 类的新实例。 + /// + /// 来自客户端的 JSON-RPC 错误码。The JSON-RPC error code from the client. + /// 来自客户端的拒绝原因说明。The rejection reason message from the client. + public McpSamplingRejectedException(int errorCode, string message) + : base($"Sampling request was rejected: [{errorCode}] {message}") + { + ErrorCode = errorCode; + RejectionMessage = message; + } + + /// + /// 获取来自客户端的 JSON-RPC 错误码。
+ /// Gets the JSON-RPC error code returned by the client. + ///
+ public int ErrorCode { get; } + + /// + /// 获取来自客户端的拒绝原因说明。
+ /// Gets the rejection reason message returned by the client. + ///
+ public string RejectionMessage { get; } +} diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Role.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Role.cs index dd8b6c1..ee420f3 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Role.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/Role.cs @@ -1,4 +1,3 @@ -using System.Runtime.Serialization; using System.Text.Json.Serialization; namespace DotNetCampus.ModelContextProtocol.Protocol.Messages; @@ -14,13 +13,13 @@ public enum Role /// 用户角色
/// User role ///
- [EnumMember(Value = "user")] + [JsonStringEnumMemberName("user")] User, /// /// 助手角色
/// Assistant role ///
- [EnumMember(Value = "assistant")] + [JsonStringEnumMemberName("assistant")] Assistant, } diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs index 0a4dfdd..7e25ad2 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs @@ -63,11 +63,11 @@ public interface IMcpServerCallToolContext : IMcpServerPrimitiveContext CancellationToken CancellationToken { get; } /// - /// 提供服务器向客户端发起 Sampling 请求的能力。当传输层或客户端不支持 Sampling 时返回
- /// Provides the ability to send Sampling requests from the server to the client. - /// Returns when the transport or client does not support Sampling. + /// 提供服务器向客户端发起 Sampling 请求的能力。始终非空;当传输层或客户端不支持 Sampling 时,
+ /// Provides the ability to send Sampling requests from the server to the client. Always non-null; + /// when the transport or client does not support Sampling, will be . ///
- IMcpServerSampling? Sampling { get; } + IMcpServerSampling Sampling { get; } } /// @@ -100,7 +100,9 @@ internal sealed class McpServerCallToolContext : IMcpServerCallToolContext public required string Name { get; init; } public required JsonElement InputJsonArguments { get; init; } public required CancellationToken CancellationToken { get; init; } - public IMcpServerSampling? Sampling => (IMcpServerSampling?)Services.GetService(typeof(IMcpServerSampling)); + public IMcpServerSampling Sampling => + (IMcpServerSampling?)Services.GetService(typeof(IMcpServerSampling)) + ?? McpServerSamplingNull.Instance; } internal sealed class McpServerReadResourceContext : IMcpServerReadResourceContext diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs index c810677..1c1fcf0 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs @@ -17,7 +17,9 @@ public interface IMcpServerSampling { /// /// 指示连接的客户端是否声明了对 Sampling 的支持。
+ /// 在调用 前应检查此属性;若为 ,调用将抛出异常。
/// Indicates whether the connected client has declared support for Sampling. + /// Check this property before calling ; if false, the call will throw. ///
bool HasSamplingCapability { get; } @@ -29,6 +31,7 @@ public interface IMcpServerSampling /// 取消令牌。Cancellation token. /// LLM 生成的采样结果。The LLM-generated sampling result. /// 当客户端未声明 Sampling 能力时抛出。Thrown when the client has not declared Sampling capability. + /// 当采样请求被用户(人工审批)拒绝时抛出。Thrown when the sampling request was rejected by the user (human-in-the-loop). Task CreateMessageAsync(CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default); } @@ -48,6 +51,7 @@ public static class McpServerSamplingExtensions /// 可选的系统提示词。Optional system prompt. /// 取消令牌。Cancellation token. /// LLM 生成的采样结果。The LLM-generated sampling result. + /// 当采样请求被用户拒绝时抛出。Thrown when the sampling request was rejected by the user. public static Task CreateMessageAsync( this IMcpServerSampling sampling, string userMessage, @@ -101,6 +105,17 @@ public async Task CreateMessageAsync( if (response.Error is { } error) { + // 根据 MCP 规范,用户拒绝审批时客户端应返回错误响应。 + // JSON-RPC 保留错误码范围为 -32768 到 -32000;任何高于 -32000 的错误码(如 -1) + // 表示用户自定义错误,通常意味着用户主动拒绝了采样请求。 + // Per the MCP spec, when a user denies a sampling request, the client returns an error response. + // JSON-RPC reserved error codes are in range -32768 to -32000; any code above -32000 (e.g., -1) + // is user-defined and typically indicates an explicit rejection by the human-in-the-loop. + if (error.Code > -32000) + { + throw new McpSamplingRejectedException(error.Code, error.Message); + } + throw new McpClientException($"Sampling request failed: [{error.Code}] {error.Message}"); } @@ -113,3 +128,24 @@ public async Task CreateMessageAsync( ?? throw new McpClientException("Failed to deserialize sampling result."); } } + +/// +/// 当传输层或客户端不支持 Sampling 时,用于占位的空对象实现。
+/// Null-object implementation of used when the transport or client does not support Sampling. +///
+internal sealed class McpServerSamplingNull : IMcpServerSampling +{ + /// + /// 获取全局单例实例。 + /// + public static readonly McpServerSamplingNull Instance = new(); + + private McpServerSamplingNull() { } + + /// + public bool HasSamplingCapability => false; + + /// + public Task CreateMessageAsync(CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("当前传输层未提供 Sampling 服务,或客户端未声明 Sampling 能力。The current transport has not provided Sampling, or the client has not declared Sampling capability."); +} diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs index b38d142..02efd54 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs @@ -15,10 +15,12 @@ public class SamplingTool [McpServerTool] public async Task AskLlm(string message, IMcpServerCallToolContext context) { - var sampling = context.Sampling - ?? throw new InvalidOperationException("Sampling service not available in this context."); + if (!context.Sampling.HasSamplingCapability) + { + throw new InvalidOperationException("Sampling service not available in this context."); + } - var result = await sampling.CreateMessageAsync(message); + var result = await context.Sampling.CreateMessageAsync(message); return result.Content is TextContentBlock textBlock ? textBlock.Text : string.Empty; } @@ -28,6 +30,6 @@ public async Task AskLlm(string message, IMcpServerCallToolContext conte [McpServerTool] public string CheckSamplingCapability(IMcpServerCallToolContext context) { - return $"has_capability={context.Sampling?.HasSamplingCapability ?? false}"; + return $"has_capability={context.Sampling.HasSamplingCapability}"; } } From c432af113a5a357f95c1d7111c0852c4daa957d8 Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 3 Apr 2026 18:43:29 +0800 Subject: [PATCH 05/77] =?UTF-8?q?=E6=95=B4=E7=90=86=E9=87=87=E6=A0=B7?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../McpTools/SamplingTool.cs | 2 +- .../McpSamplingNotSupportedException.cs | 20 +++++++++++++++ .../Servers/IMcpServerPrimitiveContext.cs | 6 ++--- .../Servers/McpServerSampling.cs | 25 ++++++++++--------- .../McpTools/SamplingTool.cs | 6 ++--- .../Servers/SamplingTests.cs | 6 ++--- 6 files changed, 43 insertions(+), 22 deletions(-) create mode 100644 src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs diff --git a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs index 4ac18b7..4cfd068 100644 --- a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs +++ b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs @@ -22,7 +22,7 @@ public async Task AskLlm( string? systemPrompt = null, IMcpServerCallToolContext context = null!) { - if (!context.Sampling.HasSamplingCapability) + if (!context.Sampling.IsSupported) { return CallToolResult.FromError( "当前客户端未声明 Sampling 能力。请确保客户端支持 sampling/createMessage 请求。\n" + diff --git a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs new file mode 100644 index 0000000..e3e1f14 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs @@ -0,0 +1,20 @@ +namespace DotNetCampus.ModelContextProtocol.Exceptions; + +/// +/// 当连接的 MCP 客户端未声明对 Sampling 能力的支持,导致无法发起 sampling/createMessage 请求时引发的异常。
+/// 此异常表示客户端在能力协商阶段未声明 sampling 能力,而非代码使用错误。
+/// Exception thrown when the connected MCP client has not declared Sampling capability support, +/// preventing a sampling/createMessage request from being sent. +/// This indicates that the client did not declare the sampling capability during negotiation, +/// not a programming error. +///
+public class McpSamplingNotSupportedException : McpClientException +{ + /// + /// 初始化 类的新实例。 + /// + public McpSamplingNotSupportedException() + : base("当前连接的客户端未声明对 Sampling 的支持。The connected client has not declared Sampling capability.") + { + } +} diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs index 7e25ad2..41c3152 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs @@ -63,9 +63,9 @@ public interface IMcpServerCallToolContext : IMcpServerPrimitiveContext CancellationToken CancellationToken { get; } /// - /// 提供服务器向客户端发起 Sampling 请求的能力。始终非空;当传输层或客户端不支持 Sampling 时,
+ /// 提供服务器向客户端发起 Sampling 请求的能力。始终非空;当传输层或客户端不支持 Sampling 时,
/// Provides the ability to send Sampling requests from the server to the client. Always non-null; - /// when the transport or client does not support Sampling, will be . + /// when the transport or client does not support Sampling, will be . ///
IMcpServerSampling Sampling { get; } } @@ -102,7 +102,7 @@ internal sealed class McpServerCallToolContext : IMcpServerCallToolContext public required CancellationToken CancellationToken { get; init; } public IMcpServerSampling Sampling => (IMcpServerSampling?)Services.GetService(typeof(IMcpServerSampling)) - ?? McpServerSamplingNull.Instance; + ?? NotSupportedMcpServerSampling.Instance; } internal sealed class McpServerReadResourceContext : IMcpServerReadResourceContext diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs index 1c1fcf0..189308d 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs @@ -17,11 +17,11 @@ public interface IMcpServerSampling { /// /// 指示连接的客户端是否声明了对 Sampling 的支持。
- /// 在调用 前应检查此属性;若为 ,调用将抛出异常。
+ /// 在调用 前应检查此属性;若为 ,调用将抛出
/// Indicates whether the connected client has declared support for Sampling. - /// Check this property before calling ; if false, the call will throw. + /// Check this property before calling ; if false, the call will throw . ///
- bool HasSamplingCapability { get; } + bool IsSupported { get; } /// /// 向客户端发送 sampling/createMessage 请求,通过客户端对 LLM 进行采样。
@@ -30,7 +30,7 @@ public interface IMcpServerSampling /// 采样请求参数。Sampling request parameters. /// 取消令牌。Cancellation token. /// LLM 生成的采样结果。The LLM-generated sampling result. - /// 当客户端未声明 Sampling 能力时抛出。Thrown when the client has not declared Sampling capability. + /// 当客户端未声明 Sampling 能力时抛出。Thrown when the client has not declared Sampling capability. /// 当采样请求被用户(人工审批)拒绝时抛出。Thrown when the sampling request was rejected by the user (human-in-the-loop). Task CreateMessageAsync(CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default); } @@ -51,6 +51,7 @@ public static class McpServerSamplingExtensions /// 可选的系统提示词。Optional system prompt. /// 取消令牌。Cancellation token. /// LLM 生成的采样结果。The LLM-generated sampling result. + /// 当客户端未声明 Sampling 能力时抛出。Thrown when the client has not declared Sampling capability. /// 当采样请求被用户拒绝时抛出。Thrown when the sampling request was rejected by the user. public static Task CreateMessageAsync( this IMcpServerSampling sampling, @@ -82,16 +83,16 @@ public static Task CreateMessageAsync( internal sealed class McpServerSampling(IServerTransportSession session) : IMcpServerSampling { /// - public bool HasSamplingCapability => session.ConnectedClientCapabilities?.Sampling is not null; + public bool IsSupported => session.ConnectedClientCapabilities?.Sampling is not null; /// public async Task CreateMessageAsync( CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default) { - if (!HasSamplingCapability) + if (!IsSupported) { - throw new InvalidOperationException("连接的客户端未声明对 Sampling 的支持。The connected client has not declared Sampling capability."); + throw new McpSamplingNotSupportedException(); } var request = new JsonRpcRequest @@ -133,19 +134,19 @@ public async Task CreateMessageAsync( /// 当传输层或客户端不支持 Sampling 时,用于占位的空对象实现。
/// Null-object implementation of used when the transport or client does not support Sampling. ///
-internal sealed class McpServerSamplingNull : IMcpServerSampling +internal sealed class NotSupportedMcpServerSampling : IMcpServerSampling { /// /// 获取全局单例实例。 /// - public static readonly McpServerSamplingNull Instance = new(); + public static readonly NotSupportedMcpServerSampling Instance = new(); - private McpServerSamplingNull() { } + private NotSupportedMcpServerSampling() { } /// - public bool HasSamplingCapability => false; + public bool IsSupported => false; /// public Task CreateMessageAsync(CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default) - => throw new InvalidOperationException("当前传输层未提供 Sampling 服务,或客户端未声明 Sampling 能力。The current transport has not provided Sampling, or the client has not declared Sampling capability."); + => throw new McpSamplingNotSupportedException(); } diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs index 02efd54..25bbb52 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/SamplingTool.cs @@ -15,7 +15,7 @@ public class SamplingTool [McpServerTool] public async Task AskLlm(string message, IMcpServerCallToolContext context) { - if (!context.Sampling.HasSamplingCapability) + if (!context.Sampling.IsSupported) { throw new InvalidOperationException("Sampling service not available in this context."); } @@ -25,11 +25,11 @@ public async Task AskLlm(string message, IMcpServerCallToolContext conte } /// - /// 检查客户端是否声明了 Sampling 能力(HasSamplingCapability)。 + /// 检查客户端是否支持 Sampling 能力(IsSupported)。 /// [McpServerTool] public string CheckSamplingCapability(IMcpServerCallToolContext context) { - return $"has_capability={context.Sampling.HasSamplingCapability}"; + return $"has_capability={context.Sampling.IsSupported}"; } } diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs index b1ab0d0..a615710 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs @@ -52,10 +52,10 @@ public async Task ServerToolCanRequestSampling(HttpTransportType transportType) Assert.AreEqual(expectedResponseText, textContent.Text, "工具返回的文本应与 Sampling 响应一致"); } - [TestMethod("Sampling: 无 Sampling 能力时 HasSamplingCapability 为 false")] + [TestMethod("Sampling: 无 Sampling 能力时 IsSupported 为 false")] [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] - public async Task HasSamplingCapabilityIsFalseWhenClientHasNoCapability(HttpTransportType transportType) + public async Task IsSupportedIsFalseWhenClientHasNoCapability(HttpTransportType transportType) { // Arrange - 客户端不配置 WithSamplingHandler,因此不声明采样能力 await using var package = await TestMcpFactory.Shared.CreateHttpCoreAsync( @@ -73,7 +73,7 @@ public async Task HasSamplingCapabilityIsFalseWhenClientHasNoCapability(HttpTran var textContent = callResult.Content.OfType().FirstOrDefault(); Assert.IsNotNull(textContent, "工具调用结果应包含文本内容"); Assert.AreEqual("has_capability=False", textContent.Text, - "当客户端未声明 Sampling 能力时,HasSamplingCapability 应为 false"); + "when客户端未声明 Sampling 能力时,IsSupported 应为 false"); } #endregion From e56fb0abc4cc8339b89051f2267815d49b1ed93d Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 7 Apr 2026 14:32:33 +0800 Subject: [PATCH 06/77] =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../McpTools/SamplingTool.cs | 10 +++------- .../McpTools/{EchoDelayTool.cs => SimpleTool.cs} | 2 +- .../Transports/ClientTransportManager.cs | 1 + 3 files changed, 5 insertions(+), 8 deletions(-) rename samples/DotNetCampus.SampleMcpServer/McpTools/{EchoDelayTool.cs => SimpleTool.cs} (99%) diff --git a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs index 4cfd068..ff1de29 100644 --- a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs +++ b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs @@ -16,7 +16,7 @@ public class SamplingTool /// 可选的系统提示词 /// MCP 工具上下文 [McpServerTool] - public async Task AskLlm( + public async Task AskLlm( string prompt, int maxTokens = 1024, string? systemPrompt = null, @@ -24,9 +24,7 @@ public async Task AskLlm( { if (!context.Sampling.IsSupported) { - return CallToolResult.FromError( - "当前客户端未声明 Sampling 能力。请确保客户端支持 sampling/createMessage 请求。\n" + - "The connected client has not declared Sampling capability."); + throw new McpToolException("当前客户端未声明 Sampling 能力。请确保客户端支持 sampling/createMessage 请求。"); } try @@ -49,9 +47,7 @@ public async Task AskLlm( } catch (McpSamplingRejectedException ex) { - return CallToolResult.FromError( - $"采样请求被用户拒绝。Sampling request was rejected by the user.\n" + - $"Code: {ex.ErrorCode}, Message: {ex.RejectionMessage}"); + throw new McpToolException($"采样请求被用户拒绝。Sampling request was rejected by the user. Code: {ex.ErrorCode}, Message: {ex.RejectionMessage}"); } } } diff --git a/samples/DotNetCampus.SampleMcpServer/McpTools/EchoDelayTool.cs b/samples/DotNetCampus.SampleMcpServer/McpTools/SimpleTool.cs similarity index 99% rename from samples/DotNetCampus.SampleMcpServer/McpTools/EchoDelayTool.cs rename to samples/DotNetCampus.SampleMcpServer/McpTools/SimpleTool.cs index ebe0338..97ddaef 100644 --- a/samples/DotNetCampus.SampleMcpServer/McpTools/EchoDelayTool.cs +++ b/samples/DotNetCampus.SampleMcpServer/McpTools/SimpleTool.cs @@ -3,7 +3,7 @@ namespace DotNetCampus.SampleMcpServer.McpTools; -public class EchoDelayTool +public class SimpleTool { /// /// 用于给 AI 调试使用的工具,原样返回一些信息 diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 110678c..e3c8ea6 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -99,6 +99,7 @@ public ValueTask HandleRespondAsync(JsonRpcResponse response, CancellationToken return ValueTask.CompletedTask; } + /// public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { From b069d8027c2e0d3d748edf0862a7a2c22066fb03 Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 7 Apr 2026 15:05:33 +0800 Subject: [PATCH 07/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/ClientTransportManager.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index e3c8ea6..0b58c2c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -103,6 +103,12 @@ public ValueTask HandleRespondAsync(JsonRpcResponse response, CancellationToken /// public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { + if (request.Id is null) + { + // JSON-RPC 2.0 规定:通知(notification)没有 id,不应发送响应。 + return; + } + JsonRpcResponse response; if (request.Method == RequestMethods.SamplingCreateMessage && _samplingHandler is { } handler) From e2c67fe978afa0bc0eec7381eb59dc148f8308ad Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 7 Apr 2026 15:08:05 +0800 Subject: [PATCH 08/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- samples/DotNetCampus.SampleMcpServer/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/DotNetCampus.SampleMcpServer/Program.cs b/samples/DotNetCampus.SampleMcpServer/Program.cs index 9d5875a..ceba127 100644 --- a/samples/DotNetCampus.SampleMcpServer/Program.cs +++ b/samples/DotNetCampus.SampleMcpServer/Program.cs @@ -32,7 +32,7 @@ private static async Task Main(string[] args) .WithRequestHandlers(s => new CustomRequestHandlers(s)) .WithJsonSerializer(McpToolJsonContext.Default) .WithTools(t => t - .WithTool(() => new EchoDelayTool()) + .WithTool(() => new SimpleTool()) .WithTool(() => new InputTool()) .WithTool(() => new OutputTool()) .WithTool(() => new PolymorphicTool()) From da9028cb82b6b03efa7375b441e617712aeab110 Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 7 Apr 2026 17:11:23 +0800 Subject: [PATCH 09/77] =?UTF-8?q?=E6=95=B4=E7=90=86=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E5=B1=82=E8=AF=BB=E5=8F=96=E8=AF=B7=E6=B1=82=E6=88=96=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E7=9A=84=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/Ipc/IpcServerTransport.cs | 78 ++++---- .../TouchSocketHttpServerTransport.cs | 174 +++++++++--------- .../Http/LocalHostHttpServerTransport.cs | 165 ++++++++--------- .../Transports/IServerTransportManager.cs | 64 +++---- .../Transports/ServerTransportManager.cs | 72 +++++--- .../Transports/Stdio/StdioServerTransport.cs | 139 ++++++-------- 6 files changed, 339 insertions(+), 353 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs index 3a61072..dd96258 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs @@ -120,29 +120,58 @@ private async Task HandleMessageAsync(PeerProxy peer, IpcMessage message) return; } - var request = await _manager.ParseAndCatchRequestAsync(payload.Body.ToMemoryStream()); - if (request is null) + JsonRpcMessage? parsed; + try { - await _manager.RespondJsonRpcAsync(peer, new JsonRpcResponse - { - Error = new JsonRpcError - { - Code = (int)JsonRpcErrorCode.InvalidRequest, - Message = "Invalid request message.", - }, - }, CancellationToken.None); - return; + parsed = await _manager.ReadMessageAsync(payload.Body.ToMemoryStream()); } - - var response = await _manager.HandleRequestAsync(request, null, CancellationToken.None); - if (response is null) + catch { - // 按照 MCP 协议规范,本次请求仅需响应而无需回复。 - // 而 IPC 不需要响应。 - return; + parsed = null; } - await _manager.RespondJsonRpcAsync(peer, response, CancellationToken.None); + switch (parsed) + { + case JsonRpcResponse response: + // 将响应路由到等待的请求(如 sampling/createMessage 回调)。 + // Route the response to the pending request (e.g. sampling/createMessage callback). + if (_sessions.TryGetValue(peer.PeerName, out var responseSession)) + { + responseSession.HandleResponseAsync(response); + } + return; + + case JsonRpcNotification notification: + // 通知,路由到处理器,无需回复。 + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + null, CancellationToken.None); + return; + + case JsonRpcRequest request: + { + var response2 = await _manager.HandleRequestAsync(request, null, CancellationToken.None); + if (response2 is null) + { + // 按照 MCP 协议规范,本次请求仅需响应而无需回复。 + // 而 IPC 不需要响应。 + return; + } + await _manager.RespondJsonRpcAsync(peer, response2, CancellationToken.None); + return; + } + + default: + await _manager.RespondJsonRpcAsync(peer, new JsonRpcResponse + { + Error = new JsonRpcError + { + Code = (int)JsonRpcErrorCode.InvalidRequest, + Message = "Invalid request message.", + }, + }, CancellationToken.None); + return; + } } } @@ -150,19 +179,6 @@ file static class Extensions { extension(IServerTransportManager manager) { - public async ValueTask ParseAndCatchRequestAsync(Stream data) - { - try - { - return await manager.ReadRequestAsync(data); - } - catch - { - // 请求消息格式不正确,返回 null 后,原样给 MCP 客户端报告错误。 - return null; - } - } - public async ValueTask RespondJsonRpcAsync(PeerProxy peer, JsonRpcResponse response, CancellationToken cancellationToken) { try diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index fb7465a..bb23c1b 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -260,13 +260,13 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca var sessionIdStr = request.Headers.Get(SessionIdHeader).First; - // 将 body 读取并解析为 JsonDocument,通过 JsonElement 检测消息类型。 + // 将 body 直接传给 ReadMessageAsync,统一解析并分类消息类型。 ReadOnlyMemory bodyBytes; - JsonDocument bodyDoc; + JsonRpcMessage? message; try { bodyBytes = await request.GetContentAsync(); - bodyDoc = JsonDocument.Parse(bodyBytes); + message = await _manager.ReadMessageAsync(bodyBytes); } catch (JsonException) { @@ -275,15 +275,9 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca return; } - using (bodyDoc) + switch (message) { - var bodyElement = bodyDoc.RootElement; - - // 检测是 JSON-RPC 请求(有 method)还是响应(无 method,有 result 或 error)。 - var isResponse = !bodyElement.TryGetProperty("method", out _) - && (bodyElement.TryGetProperty("result", out _) || bodyElement.TryGetProperty("error", out _)); - - if (isResponse) + case JsonRpcResponse jsonRpcResponse: { // 客户端响应服务器发起的请求(如 sampling/createMessage)。 if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) @@ -292,108 +286,104 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; } - - JsonRpcResponse? jsonRpcResponse; - try - { - jsonRpcResponse = await _manager.ReadResponseAsync(bodyBytes); - } - catch (JsonException) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON response"); - return; - } - - if (jsonRpcResponse is not null) - { - responseSession.HandleResponseAsync(jsonRpcResponse); - } - + responseSession.HandleResponseAsync(jsonRpcResponse); await context.RespondHttpSuccess(HttpStatusCode.Accepted); return; } - JsonRpcRequest? jsonRpcRequest; - try - { - jsonRpcRequest = await _manager.ReadRequestAsync(bodyBytes); - } - catch (JsonException) - { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Invalid JSON."); - await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); - return; - } - - if (jsonRpcRequest == null) + case JsonRpcNotification notification: { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Empty body."); - await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); + // 通知,无需响应。 + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var notificationSession)) + { + Log.Warn($"[McpServer][TouchSocket] Notification routing failed: Session not found. SessionId={sessionIdStr}"); + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + var capturedNotificationSession = notificationSession; + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + s => + { + s.AddHttpTransportServices(capturedNotificationSession.SessionId, request); + s.AddTransportSession(capturedNotificationSession); + }, + cancellationToken: cancellationToken); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); return; } - var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; - TouchSocketHttpServerTransportSession? session; - - if (isInitialize) + case JsonRpcRequest jsonRpcRequest: { - // 初始化请求,创建新 Session - var newSessionId = _manager.MakeNewSessionId(); - var newSession = new TouchSocketHttpServerTransportSession(_manager, newSessionId.Id); + var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; + TouchSocketHttpServerTransportSession? session; - if (_sessions.TryAdd(newSessionId.Id, newSession)) + if (isInitialize) { - session = newSession; - _manager.Add(session); - context.Response.Headers.Add(SessionIdHeader, newSessionId.Id); - Log.Info($"[McpServer][TouchSocket] Session created. SessionId={newSessionId.Id}"); + // 初始化请求,创建新 Session + var newSessionId = _manager.MakeNewSessionId(); + var newSession = new TouchSocketHttpServerTransportSession(_manager, newSessionId.Id); + + if (_sessions.TryAdd(newSessionId.Id, newSession)) + { + session = newSession; + _manager.Add(session); + context.Response.Headers.Add(SessionIdHeader, newSessionId.Id); + Log.Info($"[McpServer][TouchSocket] Session created. SessionId={newSessionId.Id}"); + } + else + { + Log.Error($"[McpServer][TouchSocket] Session ID collision. SessionId={newSessionId.Id}"); + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return; + } } else { - Log.Error($"[McpServer][TouchSocket] Session ID collision. SessionId={newSessionId.Id}"); - await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); - return; + if (string.IsNullOrEmpty(sessionIdStr)) + { + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header. Method={jsonRpcRequest.Method}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); + return; + } + + if (!_sessions.TryGetValue(sessionIdStr, out session)) + { + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}, Method={jsonRpcRequest.Method}"); + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } } - } - else - { - if (string.IsNullOrEmpty(sessionIdStr)) + + Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + + var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session); + }, + cancellationToken: cancellationToken); + + if (jsonRpcResponse2 != null) { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header. Method={jsonRpcRequest.Method}"); - await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); - return; + // Request: Success or Failed. + Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, jsonRpcResponse2); } - - if (!_sessions.TryGetValue(sessionIdStr, out session)) + else { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}, Method={jsonRpcRequest.Method}"); - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; + // Notification: No need to respond. + Log.Debug($"[McpServer][TouchSocket] No response for notification. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); } + return; } - Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - - var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, - s => - { - s.AddHttpTransportServices(session.SessionId, request); - s.AddTransportSession(session); - }, - cancellationToken: cancellationToken); - - if (jsonRpcResponse2 != null) - { - // Request: Success or Failed. - Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, jsonRpcResponse2); - } - else - { - // Notification: No need to respond. - Log.Debug($"[McpServer][TouchSocket] No response for notification. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await context.RespondHttpSuccess(HttpStatusCode.Accepted); - } + default: + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Invalid or unrecognized JSON-RPC message."); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); + return; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 874058e..b4add08 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -2,7 +2,6 @@ using System.Net; using System.Text; using System.Text.Json; -using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; @@ -186,11 +185,11 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat } } - // 将 body 解析为 JsonDocument,一次解析后通过 JsonElement 检测消息类型,再按需反序列化。 - JsonDocument bodyDoc; + // 将 body 直接传给 ReadMessageAsync,统一解析并分类消息类型。 + JsonRpcMessage? message; try { - bodyDoc = await JsonDocument.ParseAsync(request.InputStream, cancellationToken: cancellationToken); + message = await _manager.ReadMessageAsync(request.InputStream); } catch (JsonException) { @@ -203,17 +202,11 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat return; } - using (bodyDoc) - { - var bodyElement = bodyDoc.RootElement; - - // 检测是 JSON-RPC 请求(有 method)还是响应(无 method,有 result 或 error)。 - var isResponse = !bodyElement.TryGetProperty("method", out _) - && (bodyElement.TryGetProperty("result", out _) || bodyElement.TryGetProperty("error", out _)); + var sessionIdStr = request.Headers[SessionIdHeader]; - var sessionIdStr = request.Headers[SessionIdHeader]; - - if (isResponse) + switch (message) + { + case JsonRpcResponse jsonRpcResponse: { // 客户端响应服务器发起的请求(如 sampling/createMessage)。 if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) @@ -221,105 +214,97 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; } - - JsonRpcResponse? jsonRpcResponse; - try - { - jsonRpcResponse = bodyElement.Deserialize(McpInternalJsonContext.Default.JsonRpcResponse); - } - catch (JsonException) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON response"); - return; - } - - if (jsonRpcResponse is not null) - { - responseSession.HandleResponseAsync(jsonRpcResponse); - } - + responseSession.HandleResponseAsync(jsonRpcResponse); context.RespondHttpSuccess(HttpStatusCode.Accepted); return; } - JsonRpcRequest? jsonRpcRequest; - try + case JsonRpcNotification notification: { - jsonRpcRequest = bodyElement.Deserialize(McpInternalJsonContext.Default.JsonRpcRequest); - } - catch (JsonException) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); - return; - } - - if (jsonRpcRequest == null) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Empty body"); + // 通知,无需响应。 + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var notificationSession)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + var capturedNotificationSession = notificationSession; + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + s => s.AddTransportSession(capturedNotificationSession), + cancellationToken); + context.RespondHttpSuccess(HttpStatusCode.Accepted); return; } - var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; - LocalHostHttpServerTransportSession? session; - - if (isInitialize) + case JsonRpcRequest jsonRpcRequest: { - // 初始化请求,创建新 Session - var newSessionId = _manager.MakeNewSessionId(); - var newSession = new LocalHostHttpServerTransportSession(_manager, newSessionId.Id); + var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; + LocalHostHttpServerTransportSession? session; - if (_sessions.TryAdd(newSessionId.Id, newSession)) + if (isInitialize) { - session = newSession; - _manager.Add(session); - context.Response.AppendHeader(SessionIdHeader, newSessionId.Id); + // 初始化请求,创建新 Session + var newSessionId = _manager.MakeNewSessionId(); + var newSession = new LocalHostHttpServerTransportSession(_manager, newSessionId.Id); + + if (_sessions.TryAdd(newSessionId.Id, newSession)) + { + session = newSession; + _manager.Add(session); + context.Response.AppendHeader(SessionIdHeader, newSessionId.Id); + } + else + { + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return; + } } else { - await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); - return; - } - } - else - { - if (string.IsNullOrEmpty(sessionIdStr)) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); - return; - } + if (string.IsNullOrEmpty(sessionIdStr)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); + return; + } - if (!_sessions.TryGetValue(sessionIdStr, out session)) - { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; + if (!_sessions.TryGetValue(sessionIdStr, out session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } } - } - var capturedSession = session; - var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, - s => s.AddTransportSession(capturedSession), - cancellationToken); + var capturedSession = session; + var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, + s => s.AddTransportSession(capturedSession), + cancellationToken); - if (jsonRpcResponse2 != null) - { - // Request: Success or Failed. - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)HttpStatusCode.OK; - try + if (jsonRpcResponse2 != null) { - await _manager.WriteMessageAsync(context.Response.OutputStream, jsonRpcResponse2, cancellationToken); - context.Response.SafeClose(); + // Request: Success or Failed. + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.OK; + try + { + await _manager.WriteMessageAsync(context.Response.OutputStream, jsonRpcResponse2, cancellationToken); + context.Response.SafeClose(); + } + catch + { + // Ignore write errors + } } - catch + else { - // Ignore write errors + // Notification: No need to respond. + context.RespondHttpSuccess(HttpStatusCode.Accepted); } + return; } - else - { - // Notification: No need to respond. - context.RespondHttpSuccess(HttpStatusCode.Accepted); - } + + default: + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); + return; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs index edd8d0b..7f5412e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs @@ -47,44 +47,46 @@ public interface IServerTransportManager bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? session) where T : class, IServerTransportSession; /// - /// 提供给传输层调用。当传输层收到请求字符串行后,调用此方法可以将字符串读取为 JSON-RPC 请求对象。 + /// 提供给传输层调用。当传输层收到一行文本消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。
+ /// Available for transport implementations. Parses a single line of text into a concrete JSON-RPC message type. ///
- /// 请求字符串行。 - /// 读取出来的 JSON-RPC 请求对象,如果无法读取则返回 - /// - /// 如果读取失败,此方法会暴露底层的任何读取异常,传输层需处理好此异常(说明请求消息不正确)。 - /// - ValueTask ReadRequestAsync(string requestLine); + /// 消息文本行。A single line of text representing a JSON-RPC message. + /// + /// 解析出的消息对象,实际类型为 (有 id)、(无 id) + /// 或 (无 method)之一;无法解析时返回
+ /// The parsed message, whose runtime type is one of (has id), + /// (no id), or (no method); + /// if the input cannot be parsed. + ///
+ ValueTask ReadMessageAsync(string messageLine); /// - /// 提供给传输层调用。当传输层收到请求流后,调用此方法可以将请求流读取为 JSON-RPC 请求对象。 + /// 提供给传输层调用。当传输层收到字节流消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。
+ /// Available for transport implementations. Parses a stream into a concrete JSON-RPC message type. ///
- /// 请求流。 - /// 读取出来的 JSON-RPC 请求对象,如果无法读取则返回 - /// - /// 如果读取失败,此方法会暴露底层的任何读取异常,传输层需处理好此异常(说明请求消息不正确或连接关闭等)。 - /// - ValueTask ReadRequestAsync(Stream requestStream); - - /// - /// 提供给传输层调用。当传输层收到请求流后,调用此方法可以将请求流读取为 JSON-RPC 请求对象。 - /// - /// 请求流。 - /// 读取出来的 JSON-RPC 请求对象,如果无法读取则返回 - /// - /// 如果读取失败,此方法会暴露底层的任何读取异常,传输层需处理好此异常(说明请求消息不正确或连接关闭等)。 - /// - ValueTask ReadRequestAsync(ReadOnlyMemory requestMemory); + /// 消息流。A stream containing a JSON-RPC message. + /// + /// 解析出的消息对象,实际类型为 (有 id)、(无 id) + /// 或 (无 method)之一;无法解析时返回
+ /// The parsed message, whose runtime type is one of (has id), + /// (no id), or (no method); + /// if the input cannot be parsed. + ///
+ ValueTask ReadMessageAsync(Stream messageStream); /// - /// 提供给传输层调用。当传输层收到 JSON-RPC 响应(无 method 字段、有 result 或 error 字段)后, - /// 调用此方法可以将响应字节解析为 对象。
- /// Available for transport implementations. Parses a raw JSON-RPC response from memory - /// into a object. + /// 提供给传输层调用。当传输层收到字节内存消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。
+ /// Available for transport implementations. Parses a memory buffer into a concrete JSON-RPC message type. ///
- /// JSON-RPC 响应的原始字节。Raw bytes of the JSON-RPC response. - /// 解析出来的 JSON-RPC 响应对象,如果无法解析则返回 - ValueTask ReadResponseAsync(ReadOnlyMemory responseMemory); + /// 消息字节内存。A memory buffer containing a JSON-RPC message. + /// + /// 解析出的消息对象,实际类型为 (有 id)、(无 id) + /// 或 (无 method)之一;无法解析时返回
+ /// The parsed message, whose runtime type is one of (has id), + /// (no id), or (no method); + /// if the input cannot be parsed. + ///
+ ValueTask ReadMessageAsync(ReadOnlyMemory messageMemory); /// /// 提供给传输层调用,用于发送消息给 MCP 客户端。 diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index be3b0b6..df38d57 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -135,41 +135,63 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio return false; } - public ValueTask ReadRequestAsync(string requestLine) + public ValueTask ReadMessageAsync(string messageLine) { - var message = JsonSerializer.Deserialize(requestLine, McpInternalJsonContext.Default.JsonRpcRequest); - if (message is { Method: RequestMethods.Initialize, Id: null }) - { - return ValueTask.FromResult(message with { Id = MakeNewSessionId().ToJsonElement() }); - } - return ValueTask.FromResult(message); + using var doc = JsonDocument.Parse(messageLine); + return ValueTask.FromResult(ClassifyAndDeserialize(doc.RootElement)); } - public async ValueTask ReadRequestAsync(Stream requestStream) + public async ValueTask ReadMessageAsync(Stream messageStream) { - var message = await JsonSerializer.DeserializeAsync(requestStream, McpInternalJsonContext.Default.JsonRpcRequest); - if (message is { Method: RequestMethods.Initialize, Id: null }) - { - return message with { Id = MakeNewSessionId().ToJsonElement() }; - } - return message; + using var doc = await JsonDocument.ParseAsync(messageStream); + return ClassifyAndDeserialize(doc.RootElement); } - public async ValueTask ReadRequestAsync(ReadOnlyMemory requestMemory) + public async ValueTask ReadMessageAsync(ReadOnlyMemory messageMemory) { - var pipeReader = PipeReader.Create(new ReadOnlySequence(requestMemory)); - var message = await JsonSerializer.DeserializeAsync(pipeReader, McpInternalJsonContext.Default.JsonRpcRequest); - if (message is { Method: RequestMethods.Initialize, Id: null }) - { - return message with { Id = MakeNewSessionId().ToJsonElement() }; - } - return message; + var pipeReader = PipeReader.Create(new ReadOnlySequence(messageMemory)); + using var doc = await JsonDocument.ParseAsync(pipeReader.AsStream()); + return ClassifyAndDeserialize(doc.RootElement); } - public async ValueTask ReadResponseAsync(ReadOnlyMemory responseMemory) + /// + /// 根据 JSON-RPC 2.0 字段特征将 分类并反序列化为具体消息类型。
+ /// Classifies and deserializes a into a concrete JSON-RPC message type + /// based on the field characteristics defined by JSON-RPC 2.0. + ///
+ private JsonRpcMessage? ClassifyAndDeserialize(JsonElement element) { - var pipeReader = PipeReader.Create(new ReadOnlySequence(responseMemory)); - return await JsonSerializer.DeserializeAsync(pipeReader, McpInternalJsonContext.Default.JsonRpcResponse); + var hasMethod = element.TryGetProperty("method", out _); + + if (hasMethod) + { + // 有 id 且非 null → 请求;无 id 或 id 为 null → 通知。 + // Has id and not null → request; no id or id is null → notification. + var hasId = element.TryGetProperty("id", out var idElement) + && idElement.ValueKind != JsonValueKind.Null; + + if (hasId) + { + var request = element.Deserialize(McpInternalJsonContext.Default.JsonRpcRequest); + if (request is { Method: RequestMethods.Initialize, Id: null }) + { + return request with { Id = MakeNewSessionId().ToJsonElement() }; + } + return request; + } + else + { + return element.Deserialize(McpInternalJsonContext.Default.JsonRpcNotification); + } + } + + var hasResultOrError = element.TryGetProperty("result", out _) || element.TryGetProperty("error", out _); + if (hasResultOrError) + { + return element.Deserialize(McpInternalJsonContext.Default.JsonRpcResponse); + } + + return null; } public Task WriteMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken cancellationToken) => message switch diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs index a87a578..3b1e43f 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs @@ -1,6 +1,4 @@ using System.Text; -using System.Text.Json; -using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; using DotNetCampus.ModelContextProtocol.Servers; @@ -93,83 +91,69 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) continue; } - // 检测是请求(有 method 字段)还是响应(无 method 字段)。 - // Detect whether it's a request (has "method" field) or a response (no "method" field). - if (IsJsonRpcResponse(line)) + JsonRpcMessage? message; + try { - // 将响应路由到等待的请求。 - var response = TryParseResponse(line); - if (response is not null) - { - _session.HandleResponseAsync(response); - } - continue; + message = await _manager.ReadMessageAsync(line); } - - var request = await _manager.ParseAndCatchRequestAsync(line); - if (request is null) + catch { - await _manager.RespondJsonRpcAsync(output, new JsonRpcResponse - { - Error = new JsonRpcError - { - Code = (int)JsonRpcErrorCode.InvalidRequest, - Message = $"Invalid request message: {line}", - }, - }, cancellationToken); - continue; + message = null; } - var session = _session; - var response2 = await _manager.HandleRequestAsync(request, - s => - { - s.AddScoped(session); - s.AddScoped(new McpServerSampling(session)); - }, - cancellationToken); - if (response2 is null) + switch (message) { - // 按照 MCP 协议规范,本次请求仅需响应而无需回复。 - await output.WriteLineAsync(); - continue; - } - - await _manager.RespondJsonRpcAsync(output, response2, cancellationToken); - } - } + case JsonRpcResponse response: + // 将响应路由到等待的请求。 + // Route the response to the waiting request. + _session.HandleResponseAsync(response); + continue; + + case JsonRpcNotification notification: + // 通知,路由到处理器,无需回复。 + // Notification: route to handler, no reply expected. + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + s => + { + s.AddScoped(_session); + s.AddScoped(new McpServerSampling(_session)); + }, + cancellationToken); + continue; + + case JsonRpcRequest request: + { + var session = _session; + var response2 = await _manager.HandleRequestAsync(request, + s => + { + s.AddScoped(session); + s.AddScoped(new McpServerSampling(session)); + }, + cancellationToken); + if (response2 is null) + { + // 按照 MCP 协议规范,本次请求仅需响应而无需回复。 + await output.WriteLineAsync(); + continue; + } + await _manager.RespondJsonRpcAsync(output, response2, cancellationToken); + continue; + } - /// - /// 判断 JSON 字符串是否为 JSON-RPC 响应(没有 method 字段,有 result 或 error 字段)。 - /// - private static bool IsJsonRpcResponse(string json) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - // 请求必须有 method 字段;响应没有 method 字段但有 result 或 error。 - if (root.TryGetProperty("method", out _)) - { - return false; + default: + // 无法解析的消息,回复错误。 + await _manager.RespondJsonRpcAsync(output, new JsonRpcResponse + { + Error = new JsonRpcError + { + Code = (int)JsonRpcErrorCode.InvalidRequest, + Message = $"Invalid request message: {line}", + }, + }, cancellationToken); + continue; } - return root.TryGetProperty("result", out _) || root.TryGetProperty("error", out _); - } - catch - { - return false; - } - } - - private static JsonRpcResponse? TryParseResponse(string json) - { - try - { - return JsonSerializer.Deserialize(json, McpInternalJsonContext.Default.JsonRpcResponse); - } - catch - { - return null; } } @@ -191,19 +175,6 @@ file static class Extensions { extension(IServerTransportManager manager) { - public async ValueTask ParseAndCatchRequestAsync(string inputMessageText) - { - try - { - return await manager.ReadRequestAsync(inputMessageText); - } - catch - { - // 请求消息格式不正确,返回 null 后,原样给 MCP 客户端报告错误。 - return null; - } - } - public async ValueTask RespondJsonRpcAsync(StreamWriter writer, JsonRpcResponse response, CancellationToken cancellationToken) { try From 3db477f55dc462b30eb1b0b17a64fb1188a0b55a Mon Sep 17 00:00:00 2001 From: walterlv Date: Tue, 7 Apr 2026 17:38:06 +0800 Subject: [PATCH 10/77] =?UTF-8?q?=E9=81=BF=E5=85=8D=E4=B8=8D=E5=BA=94?= =?UTF-8?q?=E8=AF=A5=E7=9A=84=E5=8F=8C=E8=AF=AD=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 2 +- .../Transports/Ipc/IpcServerTransport.cs | 1 - .../Transports/IServerTransportManager.cs | 30 ++++++------------- .../Transports/ServerTransportManager.cs | 5 +--- .../Transports/Stdio/StdioServerTransport.cs | 2 -- 5 files changed, 11 insertions(+), 29 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c18a55..fb9836c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -42,7 +42,7 @@ src/DotNetCampus.ModelContextProtocol/ - 代码主要以最新版本协议进行编写 - 遇到需要兼容旧协议的部分,用 `Legacy` 命名相关代码并尽量减少代码量 - **协议消息类型规范**:详见 [/docs/knowledge/protocol-messages-guide.md](../docs/knowledge/protocol-messages-guide.md) - - 所有 Protocol 消息类型必须添加中英双语注释 + - **仅** `Protocol/` 文件夹下的消息类型必须添加中英双语注释;其他所有代码(接口、实现类、传输层等)一律使用**纯中文注释** - 英文注释必须使用 MCP 官方 Schema 原文 - 当前使用协议版本:**2025-11-25** - Schema 文件:[schema.ts](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts) diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs index dd96258..392476f 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs @@ -134,7 +134,6 @@ private async Task HandleMessageAsync(PeerProxy peer, IpcMessage message) { case JsonRpcResponse response: // 将响应路由到等待的请求(如 sampling/createMessage 回调)。 - // Route the response to the pending request (e.g. sampling/createMessage callback). if (_sessions.TryGetValue(peer.PeerName, out var responseSession)) { responseSession.HandleResponseAsync(response); diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs index 7f5412e..078dd10 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportManager.cs @@ -47,44 +47,32 @@ public interface IServerTransportManager bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? session) where T : class, IServerTransportSession; /// - /// 提供给传输层调用。当传输层收到一行文本消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。
- /// Available for transport implementations. Parses a single line of text into a concrete JSON-RPC message type. + /// 提供给传输层调用。当传输层收到一行文本消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。 ///
- /// 消息文本行。A single line of text representing a JSON-RPC message. + /// 消息文本行。 /// /// 解析出的消息对象,实际类型为 (有 id)、(无 id) - /// 或 (无 method)之一;无法解析时返回
- /// The parsed message, whose runtime type is one of (has id), - /// (no id), or (no method); - /// if the input cannot be parsed. + /// 或 (无 method)之一;无法解析时返回 。 ///
ValueTask ReadMessageAsync(string messageLine); /// - /// 提供给传输层调用。当传输层收到字节流消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。
- /// Available for transport implementations. Parses a stream into a concrete JSON-RPC message type. + /// 提供给传输层调用。当传输层收到字节流消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。 ///
- /// 消息流。A stream containing a JSON-RPC message. + /// 消息流。 /// /// 解析出的消息对象,实际类型为 (有 id)、(无 id) - /// 或 (无 method)之一;无法解析时返回
- /// The parsed message, whose runtime type is one of (has id), - /// (no id), or (no method); - /// if the input cannot be parsed. + /// 或 (无 method)之一;无法解析时返回 。 ///
ValueTask ReadMessageAsync(Stream messageStream); /// - /// 提供给传输层调用。当传输层收到字节内存消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。
- /// Available for transport implementations. Parses a memory buffer into a concrete JSON-RPC message type. + /// 提供给传输层调用。当传输层收到字节内存消息后,调用此方法将其解析为具体的 JSON-RPC 消息类型。 ///
- /// 消息字节内存。A memory buffer containing a JSON-RPC message. + /// 消息字节内存。 /// /// 解析出的消息对象,实际类型为 (有 id)、(无 id) - /// 或 (无 method)之一;无法解析时返回
- /// The parsed message, whose runtime type is one of (has id), - /// (no id), or (no method); - /// if the input cannot be parsed. + /// 或 (无 method)之一;无法解析时返回 。 ///
ValueTask ReadMessageAsync(ReadOnlyMemory messageMemory); diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index df38d57..d2aaa30 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -155,9 +155,7 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio } /// - /// 根据 JSON-RPC 2.0 字段特征将 分类并反序列化为具体消息类型。
- /// Classifies and deserializes a into a concrete JSON-RPC message type - /// based on the field characteristics defined by JSON-RPC 2.0. + /// 根据 JSON-RPC 2.0 字段特征将 分类并反序列化为具体消息类型。 ///
private JsonRpcMessage? ClassifyAndDeserialize(JsonElement element) { @@ -166,7 +164,6 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio if (hasMethod) { // 有 id 且非 null → 请求;无 id 或 id 为 null → 通知。 - // Has id and not null → request; no id or id is null → notification. var hasId = element.TryGetProperty("id", out var idElement) && idElement.ValueKind != JsonValueKind.Null; diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs index 3b1e43f..0a5ad4b 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs @@ -105,13 +105,11 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) { case JsonRpcResponse response: // 将响应路由到等待的请求。 - // Route the response to the waiting request. _session.HandleResponseAsync(response); continue; case JsonRpcNotification notification: // 通知,路由到处理器,无需回复。 - // Notification: route to handler, no reply expected. await _manager.HandleRequestAsync( new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, s => From 694a60274e78fd3fee694b3704f323f88b2a6a4e Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 11:18:50 +0800 Subject: [PATCH 11/77] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=9B=B4=E5=A4=9A?= =?UTF-8?q?=E7=9A=84=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TouchSocketHttpServerTransport.cs | 5 ++- ...McpServiceCollectionTransportExtensions.cs | 6 ++-- .../Servers/McpServerSampling.cs | 9 +++++- .../Transports/ClientTransportManager.cs | 11 +++++++ .../Transports/Http/HttpClientTransport.cs | 4 ++- .../Http/LocalHostHttpServerTransport.cs | 12 +++++-- .../LocalHostHttpServerTransportSession.cs | 21 ++++++++++++- .../Transports/Stdio/StdioClientTransport.cs | 3 ++ .../Transports/Stdio/StdioServerTransport.cs | 11 +++++-- .../Stdio/StdioServerTransportSession.cs | 31 ++++++++++++++++++- 10 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index bb23c1b..c0c8edc 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -6,7 +6,6 @@ using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; using DotNetCampus.ModelContextProtocol.Servers; -using DotNetCampus.ModelContextProtocol.Transports; using DotNetCampus.ModelContextProtocol.Transports.Http; using TouchSocket.Core; using TouchSocket.Http; @@ -306,7 +305,7 @@ await _manager.HandleRequestAsync( s => { s.AddHttpTransportServices(capturedNotificationSession.SessionId, request); - s.AddTransportSession(capturedNotificationSession); + s.AddTransportSession(capturedNotificationSession, Log); }, cancellationToken: cancellationToken); await context.RespondHttpSuccess(HttpStatusCode.Accepted); @@ -361,7 +360,7 @@ await _manager.HandleRequestAsync( s => { s.AddHttpTransportServices(session.SessionId, request); - s.AddTransportSession(session); + s.AddTransportSession(session, Log); }, cancellationToken: cancellationToken); diff --git a/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs b/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs index 1a7dc51..e1ae6be 100644 --- a/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs +++ b/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs @@ -1,3 +1,4 @@ +using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Servers; using DotNetCampus.ModelContextProtocol.Transports; @@ -17,11 +18,12 @@ public static class McpServiceCollectionTransportExtensions ///
/// MCP 服务集合。The MCP service collection. /// 当前传输层会话实例。The current transport session instance. + /// 日志记录器,传递给 Sampling 实现。 /// 提供链式调用的服务集合。The service collection for chaining. - public static IMcpServiceCollection AddTransportSession(this IMcpServiceCollection services, IServerTransportSession session) + public static IMcpServiceCollection AddTransportSession(this IMcpServiceCollection services, IServerTransportSession session, IMcpLogger logger) { services.AddScoped(session); - services.AddScoped(new McpServerSampling(session)); + services.AddScoped(new McpServerSampling(session, logger)); return services; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs index 189308d..43b6483 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs @@ -1,6 +1,7 @@ using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Exceptions; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -80,7 +81,7 @@ public static Task CreateMessageAsync( /// /// 的内部实现,通过关联的传输层会话与客户端通信。 /// -internal sealed class McpServerSampling(IServerTransportSession session) : IMcpServerSampling +internal sealed class McpServerSampling(IServerTransportSession session, IMcpLogger logger) : IMcpServerSampling { /// public bool IsSupported => session.ConnectedClientCapabilities?.Sampling is not null; @@ -102,6 +103,8 @@ public async Task CreateMessageAsync( Params = JsonSerializer.SerializeToElement(requestParams, McpInternalJsonContext.Default.CreateMessageRequestParams), }; + logger.Debug($"[McpServer][Mcp] Sending sampling/createMessage request. Id={request.Id}"); + var response = await session.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); if (response.Error is { } error) @@ -114,9 +117,11 @@ public async Task CreateMessageAsync( // is user-defined and typically indicates an explicit rejection by the human-in-the-loop. if (error.Code > -32000) { + logger.Warn($"[McpServer][Mcp] Sampling/createMessage rejected by user. Id={request.Id}, Code={error.Code}, Message={error.Message}"); throw new McpSamplingRejectedException(error.Code, error.Message); } + logger.Error($"[McpServer][Mcp] Sampling/createMessage failed. Id={request.Id}, Code={error.Code}, Message={error.Message}"); throw new McpClientException($"Sampling request failed: [{error.Code}] {error.Message}"); } @@ -125,6 +130,8 @@ public async Task CreateMessageAsync( throw new McpClientException("Sampling response missing result."); } + logger.Debug($"[McpServer][Mcp] Sampling/createMessage succeeded. Id={request.Id}"); + return resultElement.Deserialize(McpInternalJsonContext.Default.CreateMessageResult) ?? throw new McpClientException("Failed to deserialize sampling result."); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 0b58c2c..1dcb97f 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -3,6 +3,7 @@ using DotNetCampus.ModelContextProtocol.Clients; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Exceptions; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -94,8 +95,13 @@ public ValueTask HandleRespondAsync(JsonRpcResponse response, CancellationToken if (_pendingRequests.TryRemove(id, out var tcs)) { + Context.Logger.Debug($"[McpClient][Mcp] Response matched to pending request. Id={id}"); tcs.SetResult(response); } + else + { + Context.Logger.Warn($"[McpClient][Mcp] Received unmatched response. Id={id}"); + } return ValueTask.CompletedTask; } @@ -109,6 +115,8 @@ public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, Cancella return; } + Context.Logger.Info($"[McpClient][Mcp] Received server-initiated request. Method={request.Method}, Id={request.Id}"); + JsonRpcResponse response; if (request.Method == RequestMethods.SamplingCreateMessage && _samplingHandler is { } handler) @@ -123,6 +131,7 @@ public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, Cancella requestParams ??= new CreateMessageRequestParams { Messages = [], MaxTokens = 1024 }; var result = await handler(requestParams, cancellationToken).ConfigureAwait(false); + Context.Logger.Debug($"[McpClient][Mcp] Sampling request handled successfully. Id={request.Id}"); response = new JsonRpcResponse { Id = request.Id, @@ -131,6 +140,7 @@ public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, Cancella } catch (Exception ex) { + Context.Logger.Error($"[McpClient][Mcp] Sampling request handler threw exception. Id={request.Id}, Error={ex.Message}"); response = new JsonRpcResponse { Id = request.Id, @@ -144,6 +154,7 @@ public async ValueTask HandleServerRequestAsync(JsonRpcRequest request, Cancella } else { + Context.Logger.Warn($"[McpClient][Mcp] Unsupported server-initiated request method. Method={request.Method}, Id={request.Id}"); response = new JsonRpcResponse { Id = request.Id, diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index 8667d89..28b8172 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -151,7 +151,7 @@ private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, Cancellatio content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); request.Content = content; - _logger.Debug($"[McpClient][Http] Sending POST request. Url={requestUrl}, Type={(isInitialize ? "Initialize" : message.GetType().Name)}"); + _logger.Debug($"[McpClient][Http] → {jsonContent}"); // 4. 发送请求 (ResponseHeadersRead 以支持流式响应) var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); @@ -378,6 +378,8 @@ private async Task DispatchSseEventAsync(string? eventName, string data, Cancell if (string.IsNullOrEmpty(eventName) || string.Equals(eventName, "message", StringComparison.OrdinalIgnoreCase)) { + _logger.Debug($"[McpClient][Http] ← {data}"); + try { // 先尝试用 JsonDocument 解析来执行检查器,因为 _manager.ReadResponseAsync 会直接反序列化为对象, diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index b4add08..84958da 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -5,6 +5,7 @@ using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Http; @@ -204,6 +205,13 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat var sessionIdStr = request.Headers[SessionIdHeader]; + if (Log.IsEnabled(LoggingLevel.Debug) && message is not null) + { + using var ms = new MemoryStream(); + await _manager.WriteMessageAsync(ms, message, cancellationToken); + Log.Debug($"[McpServer][StreamableHttp] ← {Encoding.UTF8.GetString(ms.ToArray())}"); + } + switch (message) { case JsonRpcResponse jsonRpcResponse: @@ -230,7 +238,7 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat var capturedNotificationSession = notificationSession; await _manager.HandleRequestAsync( new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, - s => s.AddTransportSession(capturedNotificationSession), + s => s.AddTransportSession(capturedNotificationSession, Log), cancellationToken); context.RespondHttpSuccess(HttpStatusCode.Accepted); return; @@ -276,7 +284,7 @@ await _manager.HandleRequestAsync( var capturedSession = session; var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, - s => s.AddTransportSession(capturedSession), + s => s.AddTransportSession(capturedSession, Log), cancellationToken); if (jsonRpcResponse2 != null) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs index 4390ffc..e124875 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text; using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages; @@ -55,6 +56,8 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); } + Log.Debug($"[McpServer][StreamableHttp] Sending server-initiated request. Method={request.Method}, Id={id}, SessionId={SessionId}"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests[id] = tcs; @@ -88,8 +91,13 @@ public void HandleResponseAsync(JsonRpcResponse response) if (_pendingRequests.TryRemove(id, out var tcs)) { + Log.Debug($"[McpServer][StreamableHttp] Received client response for pending request. Id={id}, SessionId={SessionId}"); tcs.TrySetResult(response); } + else + { + Log.Warn($"[McpServer][StreamableHttp] Received unmatched client response. Id={id}, SessionId={SessionId}"); + } } public async Task RunSseConnectionAsync(Stream outputStream, CancellationToken cancellationToken) @@ -132,7 +140,18 @@ private async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, C await stream.WriteAsync(DataPrefixBytes, ct); // Serialize - await _manager.WriteMessageAsync(stream, message, ct); + if (Log.IsEnabled(LoggingLevel.Debug)) + { + using var ms = new MemoryStream(); + await _manager.WriteMessageAsync(ms, message, ct); + var json = Encoding.UTF8.GetString(ms.ToArray()); + Log.Debug($"[McpServer][StreamableHttp] → {json}"); + await stream.WriteAsync(ms.ToArray(), ct); + } + else + { + await _manager.WriteMessageAsync(stream, message, ct); + } // \n\n (End of event) await stream.WriteAsync(NewLineBytes, ct); diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs index 0edac7c..039d519 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs @@ -70,6 +70,7 @@ public async ValueTask SendMessageAsync(JsonRpcMessage message, CancellationToke } var line = _manager.WriteMessageAsync(message); + Log.Debug($"[McpClient][Stdio] → {line}"); await stdio.StandardInput.WriteAsync(line); await stdio.StandardInput.WriteAsync('\n'); await stdio.StandardInput.FlushAsync(); @@ -105,6 +106,8 @@ private async Task RunLoopAsync(StdioProcessInfo stdio, CancellationToken cancel continue; } + Log.Debug($"[McpClient][Stdio] ← {line}"); + // 检测是服务器主动发起的请求(有 method),还是对客户端请求的响应(有 result/error)。 if (IsServerRequest(line)) { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs index 0a5ad4b..1425de8 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs @@ -29,7 +29,7 @@ public class StdioServerTransport : IServerTransport public StdioServerTransport(IServerTransportManager manager) { _manager = manager; - _session = new StdioServerTransportSession(); + _session = new StdioServerTransportSession(manager.Context.Logger); } private IMcpLogger Log => _manager.Context.Logger; @@ -83,6 +83,7 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) var line = await input.ReadLineAsync(cancellationToken); if (line is null) { + Log.Info($"[McpServer][Stdio] Client disconnected (end of input stream)."); break; } @@ -91,6 +92,8 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) continue; } + Log.Debug($"[McpServer][Stdio] ← {line}"); + JsonRpcMessage? message; try { @@ -105,6 +108,7 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) { case JsonRpcResponse response: // 将响应路由到等待的请求。 + Log.Debug($"[McpServer][Stdio] Routing client response to session."); _session.HandleResponseAsync(response); continue; @@ -115,7 +119,7 @@ await _manager.HandleRequestAsync( s => { s.AddScoped(_session); - s.AddScoped(new McpServerSampling(_session)); + s.AddScoped(new McpServerSampling(_session, Log)); }, cancellationToken); continue; @@ -127,7 +131,7 @@ await _manager.HandleRequestAsync( s => { s.AddScoped(session); - s.AddScoped(new McpServerSampling(session)); + s.AddScoped(new McpServerSampling(session, Log)); }, cancellationToken); if (response2 is null) @@ -142,6 +146,7 @@ await _manager.HandleRequestAsync( default: // 无法解析的消息,回复错误。 + Log.Warn($"[McpServer][Stdio] Received unrecognizable message, responding with error."); await _manager.RespondJsonRpcAsync(output, new JsonRpcResponse { Error = new JsonRpcError diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index ba444f8..3d6eb21 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -15,8 +16,18 @@ public class StdioServerTransportSession : IServerTransportSession private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); private readonly ConcurrentDictionary> _pendingRequests = []; private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly IMcpLogger _logger; private StreamWriter? _output; + /// + /// 初始化 类的新实例。 + /// + /// 日志记录器。 + public StdioServerTransportSession(IMcpLogger logger) + { + _logger = logger; + } + /// /// STDIO 传输层是专用的,不需要会话 ID。 /// @@ -44,7 +55,18 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await JsonSerializer.SerializeAsync(output.BaseStream, message, GetTypeInfo(message), cancellationToken).ConfigureAwait(false); + if (_logger.IsEnabled(LoggingLevel.Debug)) + { + using var ms = new MemoryStream(); + await JsonSerializer.SerializeAsync(ms, message, GetTypeInfo(message), cancellationToken).ConfigureAwait(false); + var json = Encoding.UTF8.GetString(ms.ToArray()); + _logger.Debug($"[McpServer][Stdio] → {json}"); + await output.BaseStream.WriteAsync(ms.ToArray(), cancellationToken).ConfigureAwait(false); + } + else + { + await JsonSerializer.SerializeAsync(output.BaseStream, message, GetTypeInfo(message), cancellationToken).ConfigureAwait(false); + } await output.BaseStream.WriteAsync(NewLineBytes, cancellationToken).ConfigureAwait(false); await output.BaseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } @@ -62,6 +84,8 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); } + _logger.Debug($"[McpServer][Stdio] Sending server-initiated request. Method={request.Method}, Id={id}"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests[id] = tcs; @@ -94,8 +118,13 @@ public void HandleResponseAsync(JsonRpcResponse response) if (_pendingRequests.TryRemove(id, out var tcs)) { + _logger.Debug($"[McpServer][Stdio] Received client response for pending request. Id={id}"); tcs.TrySetResult(response); } + else + { + _logger.Warn($"[McpServer][Stdio] Received unmatched client response. Id={id}"); + } } /// From 9acc4e66e4414ee3988d97d077edd7f6e5023004 Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 11:51:26 +0800 Subject: [PATCH 12/77] =?UTF-8?q?=E5=85=BC=E5=AE=B9=20MCP=20Inspector=20?= =?UTF-8?q?=E7=9A=84=E9=87=87=E6=A0=B7=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Servers/McpServerSampling.cs | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs index 43b6483..dcaa96c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs @@ -11,49 +11,43 @@ namespace DotNetCampus.ModelContextProtocol.Servers; /// -/// 提供服务器主动向客户端发起 Sampling(AI 采样)请求的能力。
-/// Provides the server's ability to initiate Sampling (AI sampling) requests to the client. +/// 提供服务器主动向客户端发起 Sampling(AI 采样)请求的能力。 ///
public interface IMcpServerSampling { /// /// 指示连接的客户端是否声明了对 Sampling 的支持。
- /// 在调用 前应检查此属性;若为 ,调用将抛出
- /// Indicates whether the connected client has declared support for Sampling. - /// Check this property before calling ; if false, the call will throw . + /// 在调用 前应检查此属性;若为 ,调用将抛出 。 ///
bool IsSupported { get; } /// - /// 向客户端发送 sampling/createMessage 请求,通过客户端对 LLM 进行采样。
- /// Sends a sampling/createMessage request to the client to sample from an LLM via the client. + /// 向客户端发送 sampling/createMessage 请求,通过客户端对 LLM 进行采样。 ///
- /// 采样请求参数。Sampling request parameters. - /// 取消令牌。Cancellation token. - /// LLM 生成的采样结果。The LLM-generated sampling result. - /// 当客户端未声明 Sampling 能力时抛出。Thrown when the client has not declared Sampling capability. - /// 当采样请求被用户(人工审批)拒绝时抛出。Thrown when the sampling request was rejected by the user (human-in-the-loop). + /// 采样请求参数。 + /// 取消令牌。 + /// LLM 生成的采样结果。 + /// 当客户端未声明 Sampling 能力时抛出。 + /// 当采样请求被用户(人工审批)拒绝时抛出。 Task CreateMessageAsync(CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default); } /// -/// 的扩展方法,提供便捷的文本采样接口。
-/// Extension methods for , providing convenient text-only sampling APIs. +/// 的扩展方法,提供便捷的文本采样接口。 ///
public static class McpServerSamplingExtensions { /// - /// 向客户端发送简单的纯文本采样请求。
- /// Sends a simple plain-text sampling request to the client. + /// 向客户端发送简单的纯文本采样请求。 ///
- /// 采样服务实例。Sampling service instance. - /// 用户消息内容。User message content. - /// 最大生成令牌数。Maximum number of tokens to generate. - /// 可选的系统提示词。Optional system prompt. - /// 取消令牌。Cancellation token. - /// LLM 生成的采样结果。The LLM-generated sampling result. - /// 当客户端未声明 Sampling 能力时抛出。Thrown when the client has not declared Sampling capability. - /// 当采样请求被用户拒绝时抛出。Thrown when the sampling request was rejected by the user. + /// 采样服务实例。 + /// 用户消息内容。 + /// 最大生成令牌数。 + /// 可选的系统提示词。 + /// 取消令牌。 + /// LLM 生成的采样结果。 + /// 当客户端未声明 Sampling 能力时抛出。 + /// 当采样请求被用户拒绝时抛出。 public static Task CreateMessageAsync( this IMcpServerSampling sampling, string userMessage, @@ -112,10 +106,18 @@ public async Task CreateMessageAsync( // 根据 MCP 规范,用户拒绝审批时客户端应返回错误响应。 // JSON-RPC 保留错误码范围为 -32768 到 -32000;任何高于 -32000 的错误码(如 -1) // 表示用户自定义错误,通常意味着用户主动拒绝了采样请求。 - // Per the MCP spec, when a user denies a sampling request, the client returns an error response. - // JSON-RPC reserved error codes are in range -32768 to -32000; any code above -32000 (e.g., -1) - // is user-defined and typically indicates an explicit rejection by the human-in-the-loop. - if (error.Code > -32000) + // + // 兼容性说明(MCP Inspector 的不规范行为): + // MCP Inspector 拒绝采样时,调用的是 reject(new Error("Sampling request rejected")), + // 传入的是普通 JavaScript Error,没有 code 属性。 + // TypeScript SDK 在将 handler rejection 转换为 JSON-RPC 错误时, + // 其逻辑为:code = Number.isSafeInteger(error['code']) ? error['code'] : ErrorCode.InternalError + // 即当 error 无 code 时回退到 -32603(InternalError),而非规范要求的 -1。 + // 因此需额外检查 -32603 + 消息关键字来识别这类不规范的拒绝响应, + // 同时避免将真正的服务端内部错误误判为用户拒绝。 + var isRejectedByUser = error.Code > -32000 + || (error.Code == -32603 && error.Message.Contains("reject", StringComparison.OrdinalIgnoreCase)); + if (isRejectedByUser) { logger.Warn($"[McpServer][Mcp] Sampling/createMessage rejected by user. Id={request.Id}, Code={error.Code}, Message={error.Message}"); throw new McpSamplingRejectedException(error.Code, error.Message); @@ -138,8 +140,7 @@ public async Task CreateMessageAsync( } /// -/// 当传输层或客户端不支持 Sampling 时,用于占位的空对象实现。
-/// Null-object implementation of used when the transport or client does not support Sampling. +/// 当传输层或客户端不支持 Sampling 时,用于占位的空对象实现。 ///
internal sealed class NotSupportedMcpServerSampling : IMcpServerSampling { From 301053587e2444adcbbd346b7e9e957117229c1e Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 11:53:10 +0800 Subject: [PATCH 13/77] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Servers/McpServerRequestHandlers.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs index c52bb2a..5ee6d3e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs @@ -62,10 +62,12 @@ public virtual ValueTask InitializeAsync( CancellationToken cancellationToken) { var clientInfo = request.Params?.ClientInfo; - Logger.Info($"[McpServer][Mcp] Client initializing. ClientName={clientInfo?.Name}, ClientVersion={clientInfo?.Version}, ProtocolVersion={request.Params?.ProtocolVersion}"); + Logger.Info( + $"[McpServer][Mcp] Client initializing. ClientName={clientInfo?.Name}, ClientVersion={clientInfo?.Version}, ProtocolVersion={request.Params?.ProtocolVersion}"); // 将客户端能力保存到当前传输层会话,以便后续服务器发起请求(如 sampling)时判断能力。 - var session = (DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession?)request.Services.GetService(typeof(DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession)); + var session = (DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession?)request.Services.GetService( + typeof(DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession)); if (session is not null && request.Params?.Capabilities is { } capabilities) { session.ConnectedClientCapabilities = capabilities; @@ -96,7 +98,8 @@ public virtual ValueTask InitializeAsync( }, }; - Logger.Info($"[McpServer][Mcp] Server initialized. ServerName={_server.ServerName}, ServerVersion={_server.ServerVersion}, ToolCount={_server.Tools.Count}, ResourceCount={_server.Resources.Count}"); + Logger.Info( + $"[McpServer][Mcp] Server initialized. ServerName={_server.ServerName}, ServerVersion={_server.ServerVersion}, ToolCount={_server.Tools.Count}, ResourceCount={_server.Resources.Count}"); return ValueTask.FromResult(result); } @@ -341,6 +344,12 @@ public virtual async ValueTask CallToolAsync( Logger.Warn($"[McpServer][Mcp] Tool call failed. ToolName={toolName}, Arguments={rawRequest.Params}, Error={ex.Message}"); return CallToolResult.FromException(ex); } + catch (McpClientException ex) + { + // 此错误来自 MCP 客户端(例如工具调用过程中,服务端反向发起了请求,但客户端未能正确响应请求)。 + Logger.Warn($"[McpServer][Mcp] Tool call failed: Client error. ToolName={toolName}, Arguments={rawRequest.Params}, Error={ex.Message}"); + return CallToolResult.FromException(ex); + } catch (Exception ex) { // 其他未知错误。 From e2eba44f5452e51309c01ecc52abaa49c8d056c1 Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 14:27:21 +0800 Subject: [PATCH 14/77] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=8D=8F=E8=AE=AE=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fb9836c..9cdf4a5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -75,8 +75,9 @@ src/DotNetCampus.ModelContextProtocol/ ## 参考资源 -- [MCP 官方规范 (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) - **当前使用版本** -- [MCP Schema (2025-06-18)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts) - **官方消息类型定义** +- [MCP 官方规范 (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) - **当前使用版本** +- [MCP Schema (2025-11-25)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts) - **官方消息类型定义** +- [MCP 官方规范 (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) - 旧版本(兼容支持) - [MCP 官方规范 (2024-11-05)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) - 旧版本(兼容支持) - [JSON-RPC 2.0 规范](https://www.jsonrpc.org/specification) - [SSE 标准](https://html.spec.whatwg.org/multipage/server-sent-events.html) From 8dceafed92e5957a8ff6f95f7bb310e3864a1230 Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 17:05:55 +0800 Subject: [PATCH 15/77] =?UTF-8?q?AI=20=E5=A2=9E=E5=8A=A0=E7=9A=84=E5=B1=8E?= =?UTF-8?q?=E5=B1=B1=E4=BB=A3=E7=A0=81=EF=BC=9A=E4=BF=AE=E5=A4=8D=20SSE=20?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B5=81=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Ipc/IpcServerTransportSession.cs | 14 ++++ .../TouchSocketHttpServerTransport.cs | 83 +++++++++++++++---- .../TouchSocketHttpServerTransportSession.cs | 56 ++++++++++++- .../Http/LocalHostHttpServerTransport.cs | 76 +++++++++++++---- .../LocalHostHttpServerTransportSession.cs | 57 ++++++++++++- .../Transports/IServerTransportSession.cs | 11 ++- .../Stdio/StdioServerTransportSession.cs | 14 ++++ 7 files changed, 278 insertions(+), 33 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs index 40594bf..0fe1ea9 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Threading.Channels; using dotnetCampus.Ipc.Pipes; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -88,6 +89,13 @@ public void HandleResponseAsync(JsonRpcResponse response) } } + /// + public IDisposable AttachRequestSseChannel(ChannelWriter writer) + { + // IPC 传输层是全双工管道,不需要 per-request SSE 通道,此方法为空实现。 + return NopDisposable.Instance; + } + /// public ValueTask DisposeAsync() { @@ -98,4 +106,10 @@ public ValueTask DisposeAsync() _pendingRequests.Clear(); return ValueTask.CompletedTask; } + + private sealed class NopDisposable : IDisposable + { + public static readonly NopDisposable Instance = new(); + public void Dispose() { } + } } diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index c0c8edc..c252a25 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Collections.Specialized; using System.Text.Json; +using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; @@ -356,25 +357,77 @@ await _manager.HandleRequestAsync( Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, - s => - { - s.AddHttpTransportServices(session.SessionId, request); - s.AddTransportSession(session, Log); - }, - cancellationToken: cancellationToken); - - if (jsonRpcResponse2 != null) + if (isInitialize) { - // Request: Success or Failed. - Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, jsonRpcResponse2); + // initialize 请求:直接返回 application/json,不需要 SSE 流。 + var initResponse = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + + if (initResponse != null) + { + Log.Debug($"[McpServer][TouchSocket] Sending initialize response. SessionId={session.SessionId}, MessageId={jsonRpcRequest.Id}"); + await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, initResponse); + } + else + { + Log.Debug($"[McpServer][TouchSocket] No response for initialize notification. SessionId={session.SessionId}"); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } } else { - // Notification: No need to respond. - Log.Debug($"[McpServer][TouchSocket] No response for notification. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await context.RespondHttpSuccess(HttpStatusCode.Accepted); + // 非 initialize 请求:以 text/event-stream 响应,允许服务端在处理期间发起 sampling 等请求。 + // 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending + // the JSON-RPC response. These messages SHOULD relate to the originating client request." + var requestChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + context.Response.SetStatus(HttpStatusCode.OK, ""); + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.Add("Cache-Control", "no-cache"); + + using var channelRegistration = session.AttachRequestSseChannel(requestChannel.Writer); + + // 并发:(a) 处理请求,完成后把响应写入 Channel;(b) 消费 Channel,写入 SSE 流。 + var handleTask = Task.Run(async () => + { + try + { + var resp = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + if (resp != null) + { + Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response via SSE. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + await requestChannel.Writer.WriteAsync(resp, cancellationToken); + } + } + finally + { + requestChannel.Writer.TryComplete(); + } + }, cancellationToken); + + context.Response.IsChunk = true; + await using var output = context.Response.CreateWriteStream(); + await output.WriteAsync(PrimeEventBytes, cancellationToken); + await output.FlushAsync(cancellationToken); + + await session.RunRequestSseAsync(requestChannel, output, cancellationToken); + await handleTask; // 确保处理完成,传播异常 + await context.Response.CompleteChunkAsync(cancellationToken); } return; } diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs index a8d1131..c15d638 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs @@ -20,6 +20,13 @@ public class TouchSocketHttpServerTransportSession : IServerTransportSession private readonly CancellationTokenSource _disposeCts = new(); private readonly ConcurrentDictionary> _pendingRequests = []; + /// + /// 当前 POST 请求绑定的 per-request SSE 写入通道。 + /// 非 null 时,SendMessageAsync/SendRequestAsync 优先写入此通道(走 POST 响应 SSE 流)。 + /// null 时回退到 _outgoingMessages(走 GET SSE 流)。 + /// + private volatile ChannelWriter? _requestSseWriter; + private IMcpLogger Log => _manager.Context.Logger; /// @@ -44,6 +51,18 @@ public TouchSocketHttpServerTransportSession(IServerTransportManager manager, st }); } + /// + public IDisposable AttachRequestSseChannel(ChannelWriter writer) + { + _requestSseWriter = writer; + return new RequestSseChannelRegistration(this); + } + + private void DetachRequestSseChannel() + { + _requestSseWriter = null; + } + /// public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { @@ -51,6 +70,12 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat { return Task.CompletedTask; } + // 优先写入 per-request 通道(POST 响应 SSE 流);否则走全局 GET SSE 通道。 + var writer = _requestSseWriter; + if (writer is not null) + { + return writer.WriteAsync(message, cancellationToken).AsTask(); + } return _outgoingMessages.Writer.WriteAsync(message, cancellationToken).AsTask(); } @@ -75,6 +100,7 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc try { + // 通过 SSE 通道将请求发送给客户端(优先 per-request,否则 GET SSE)。 await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); return await tcs.Task.ConfigureAwait(false); } @@ -132,7 +158,30 @@ public async Task RunSseConnectionAsync(Stream outputStream, CancellationToken c } } - private async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) + /// + /// 运行 per-request SSE 流:持续消费 中的消息并写入 , + /// 直到 channel 完成(Complete)或取消。 + /// + public async Task RunRequestSseAsync(Channel channel, Stream outputStream, CancellationToken cancellationToken) + { + try + { + await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) + { + await WriteSseMessageAsync(outputStream, message, cancellationToken); + } + } + catch (OperationCanceledException) + { + // 正常取消 + } + catch (Exception ex) + { + Log.Warn($"[McpServer][TouchSocket] Per-request SSE error. SessionId={SessionId}, Error={ex.Message}"); + } + } + + internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) { try { @@ -180,4 +229,9 @@ public async ValueTask DisposeAsync() _pendingRequests.Clear(); _disposeCts.Dispose(); } + + private sealed class RequestSseChannelRegistration(TouchSocketHttpServerTransportSession session) : IDisposable + { + public void Dispose() => session.DetachRequestSseChannel(); + } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 84958da..9678403 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text; using System.Text.Json; +using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; @@ -283,29 +284,76 @@ await _manager.HandleRequestAsync( } var capturedSession = session; - var jsonRpcResponse2 = await _manager.HandleRequestAsync(jsonRpcRequest, - s => s.AddTransportSession(capturedSession, Log), - cancellationToken); - if (jsonRpcResponse2 != null) + if (isInitialize) { - // Request: Success or Failed. - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)HttpStatusCode.OK; - try + // initialize 请求:直接返回 application/json,不需要 SSE 流。 + var initResponse = await _manager.HandleRequestAsync(jsonRpcRequest, + s => s.AddTransportSession(capturedSession, Log), + cancellationToken); + + if (initResponse != null) { - await _manager.WriteMessageAsync(context.Response.OutputStream, jsonRpcResponse2, cancellationToken); - context.Response.SafeClose(); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.OK; + try + { + await _manager.WriteMessageAsync(context.Response.OutputStream, initResponse, cancellationToken); + context.Response.SafeClose(); + } + catch + { + // Ignore write errors + } } - catch + else { - // Ignore write errors + context.RespondHttpSuccess(HttpStatusCode.Accepted); } } else { - // Notification: No need to respond. - context.RespondHttpSuccess(HttpStatusCode.Accepted); + // 非 initialize 请求:以 text/event-stream 响应,允许服务端在处理期间发起 sampling 等请求。 + // 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending + // the JSON-RPC response. These messages SHOULD relate to the originating client request." + var requestChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + context.Response.StatusCode = (int)HttpStatusCode.OK; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers["Cache-Control"] = "no-cache"; + + using var channelRegistration = capturedSession.AttachRequestSseChannel(requestChannel.Writer); + + // 并发:(a) 处理请求,完成后把响应写入 Channel;(b) 消费 Channel,写入 SSE 流。 + var handleTask = Task.Run(async () => + { + try + { + var resp = await _manager.HandleRequestAsync(jsonRpcRequest, + s => s.AddTransportSession(capturedSession, Log), + cancellationToken); + if (resp != null) + { + await requestChannel.Writer.WriteAsync(resp, cancellationToken); + } + } + finally + { + requestChannel.Writer.TryComplete(); + } + }, cancellationToken); + + var output = context.Response.OutputStream; + await output.WriteAsync(PrimeEventBytes, cancellationToken); + await output.FlushAsync(cancellationToken); + + await capturedSession.RunRequestSseAsync(requestChannel, output, cancellationToken); + await handleTask; // 确保处理完成,传播异常 + context.Response.SafeClose(); } return; } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs index e124875..1f697bc 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs @@ -21,6 +21,13 @@ internal class LocalHostHttpServerTransportSession : IServerTransportSession private readonly CancellationTokenSource _disposeCts = new(); private readonly ConcurrentDictionary> _pendingRequests = []; + /// + /// 当前 POST 请求绑定的 per-request SSE 写入通道。 + /// 非 null 时,SendMessageAsync/SendRequestAsync 优先写入此通道(走 POST 响应 SSE 流)。 + /// null 时回退到 _outgoingMessages(走 GET SSE 流)。 + /// + private volatile ChannelWriter? _requestSseWriter; + private IMcpLogger Log => _manager.Context.Logger; public string SessionId { get; } @@ -39,12 +46,30 @@ public LocalHostHttpServerTransportSession(IServerTransportManager manager, stri }); } + /// + public IDisposable AttachRequestSseChannel(ChannelWriter writer) + { + _requestSseWriter = writer; + return new RequestSseChannelRegistration(this); + } + + private void DetachRequestSseChannel() + { + _requestSseWriter = null; + } + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { if (_disposeCts.IsCancellationRequested) { return Task.CompletedTask; } + // 优先写入 per-request 通道(POST 响应 SSE 流);否则走全局 GET SSE 通道。 + var writer = _requestSseWriter; + if (writer is not null) + { + return writer.WriteAsync(message, cancellationToken).AsTask(); + } return _outgoingMessages.Writer.WriteAsync(message, cancellationToken).AsTask(); } @@ -71,7 +96,7 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc try { - // 通过 SSE 通道将请求发送给客户端。 + // 通过 SSE 通道将请求发送给客户端(优先 per-request,否则 GET SSE)。 await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); return await tcs.Task.ConfigureAwait(false); } @@ -129,7 +154,30 @@ public async Task RunSseConnectionAsync(Stream outputStream, CancellationToken c } } - private async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) + /// + /// 运行 per-request SSE 流:持续消费 中的消息并写入 , + /// 直到 channel 完成(Complete)或取消。 + /// + public async Task RunRequestSseAsync(Channel channel, Stream outputStream, CancellationToken cancellationToken) + { + try + { + await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) + { + await WriteSseMessageAsync(outputStream, message, cancellationToken); + } + } + catch (OperationCanceledException) + { + // 正常取消 + } + catch (Exception ex) + { + Log.Warn($"[McpServer][StreamableHttp] Per-request SSE error. SessionId={SessionId}, Error={ex.Message}"); + } + } + + internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) { try { @@ -187,4 +235,9 @@ public async ValueTask DisposeAsync() _pendingRequests.Clear(); _disposeCts.Dispose(); } + + private sealed class RequestSseChannelRegistration(LocalHostHttpServerTransportSession session) : IDisposable + { + public void Dispose() => session.DetachRequestSseChannel(); + } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs index 6c84152..844c456 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs @@ -1,4 +1,5 @@ -using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using System.Threading.Channels; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports; @@ -38,4 +39,12 @@ public interface IServerTransportSession : IAsyncDisposable /// Handles a JSON-RPC response received from the client (a reply to a server-initiated request). ///
void HandleResponseAsync(JsonRpcResponse response); + + /// + /// 为当前正在处理的 POST 请求注册一个专属的 SSE 写入通道。 + /// 注册后, 会优先将消息写入此通道, + /// 而非全局的 GET SSE 通道,从而让服务端主动请求(如采样)与触发它的工具调用使用同一条 SSE 流。 + /// Dispose 返回的对象可注销通道(恢复到全局 GET SSE)。 + /// + IDisposable AttachRequestSseChannel(ChannelWriter writer); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index 3d6eb21..06e1fdc 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text; using System.Text.Json; +using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages; @@ -127,6 +128,13 @@ public void HandleResponseAsync(JsonRpcResponse response) } } + /// + public IDisposable AttachRequestSseChannel(ChannelWriter writer) + { + // STDIO 传输层是全双工管道,不需要 per-request SSE 通道,此方法为空实现。 + return NopDisposable.Instance; + } + /// public ValueTask DisposeAsync() { @@ -146,4 +154,10 @@ public ValueTask DisposeAsync() JsonRpcNotification notification => McpInternalJsonContext.Default.JsonRpcNotification, _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), }; + + private sealed class NopDisposable : IDisposable + { + public static readonly NopDisposable Instance = new(); + public void Dispose() { } + } } From 733ca8cb0612206aae3f67fc864359fbc661f8f0 Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 17:46:51 +0800 Subject: [PATCH 16/77] =?UTF-8?q?=E7=AE=80=E5=8C=96=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E5=B1=82=E7=9A=84=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Ipc/IpcServerTransportSession.cs | 24 +--- .../TouchSocketHttpServerTransport.cs | 58 ++++----- .../TouchSocketHttpServerTransportSession.cs | 114 +++--------------- .../Http/LocalHostHttpServerTransport.cs | 48 +++----- .../LocalHostHttpServerTransportSession.cs | 108 +++-------------- .../Transports/IServerTransportSession.cs | 17 +-- .../Stdio/StdioServerTransportSession.cs | 13 -- 7 files changed, 80 insertions(+), 302 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs index 0fe1ea9..7bc9d9c 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using System.Threading.Channels; using dotnetCampus.Ipc.Pipes; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -39,12 +38,6 @@ internal void SetPeer(PeerProxy peer) _peer = peer; } - /// - public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - /// public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { @@ -66,8 +59,8 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc try { - await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); - return await tcs.Task.ConfigureAwait(false); + // IPC 传输层的服务端主动请求尚未实现。 + throw new NotImplementedException("IPC 传输层尚不支持服务端主动发起请求(如 sampling/createMessage)。"); } finally { @@ -89,13 +82,6 @@ public void HandleResponseAsync(JsonRpcResponse response) } } - /// - public IDisposable AttachRequestSseChannel(ChannelWriter writer) - { - // IPC 传输层是全双工管道,不需要 per-request SSE 通道,此方法为空实现。 - return NopDisposable.Instance; - } - /// public ValueTask DisposeAsync() { @@ -106,10 +92,4 @@ public ValueTask DisposeAsync() _pendingRequests.Clear(); return ValueTask.CompletedTask; } - - private sealed class NopDisposable : IDisposable - { - public static readonly NopDisposable Instance = new(); - public void Dispose() { } - } } diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index c252a25..4831c23 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Specialized; using System.Text.Json; -using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; @@ -226,7 +225,12 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, await output.WriteAsync(PrimeEventBytes, cancellationToken); await output.FlushAsync(cancellationToken); - await session.RunSseConnectionAsync(output, cancellationToken); + // 保持连接,暂不主动推送;未来实现全局推送时在此扩展。 + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (OperationCanceledException) + { + // 正常关闭 } catch (Exception ex) { @@ -384,49 +388,31 @@ await _manager.HandleRequestAsync( // 非 initialize 请求:以 text/event-stream 响应,允许服务端在处理期间发起 sampling 等请求。 // 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending // the JSON-RPC response. These messages SHOULD relate to the originating client request." - var requestChannel = Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - }); - context.Response.SetStatus(HttpStatusCode.OK, ""); context.Response.ContentType = "text/event-stream"; context.Response.Headers.Add("Cache-Control", "no-cache"); - using var channelRegistration = session.AttachRequestSseChannel(requestChannel.Writer); - - // 并发:(a) 处理请求,完成后把响应写入 Channel;(b) 消费 Channel,写入 SSE 流。 - var handleTask = Task.Run(async () => - { - try - { - var resp = await _manager.HandleRequestAsync(jsonRpcRequest, - s => - { - s.AddHttpTransportServices(session.SessionId, request); - s.AddTransportSession(session, Log); - }, - cancellationToken: cancellationToken); - if (resp != null) - { - Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response via SSE. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await requestChannel.Writer.WriteAsync(resp, cancellationToken); - } - } - finally - { - requestChannel.Writer.TryComplete(); - } - }, cancellationToken); - context.Response.IsChunk = true; await using var output = context.Response.CreateWriteStream(); await output.WriteAsync(PrimeEventBytes, cancellationToken); await output.FlushAsync(cancellationToken); - await session.RunRequestSseAsync(requestChannel, output, cancellationToken); - await handleTask; // 确保处理完成,传播异常 + // 绑定 SSE 流:HandleRequestAsync 执行期间,SendRequestAsync 直接向 output 写采样请求。 + using var _ = session.SetRequestSseStream(output); + + var resp = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + + if (resp != null) + { + Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response via SSE. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + await session.WriteSseMessageAsync(output, resp, cancellationToken); + } await context.Response.CompleteChunkAsync(cancellationToken); } return; diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs index c15d638..6c52562 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; using DotNetCampus.ModelContextProtocol.Hosting.Logging; @@ -16,16 +15,14 @@ public class TouchSocketHttpServerTransportSession : IServerTransportSession private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); private readonly IServerTransportManager _manager; - private readonly Channel _outgoingMessages; private readonly CancellationTokenSource _disposeCts = new(); private readonly ConcurrentDictionary> _pendingRequests = []; /// - /// 当前 POST 请求绑定的 per-request SSE 写入通道。 - /// 非 null 时,SendMessageAsync/SendRequestAsync 优先写入此通道(走 POST 响应 SSE 流)。 - /// null 时回退到 _outgoingMessages(走 GET SSE 流)。 + /// 当前 POST 请求绑定的 SSE 输出流。 + /// 非 null 时,SendRequestAsync 直接向此流写入采样请求。 /// - private volatile ChannelWriter? _requestSseWriter; + private volatile Stream? _currentRequestSseStream; private IMcpLogger Log => _manager.Context.Logger; @@ -44,39 +41,21 @@ public TouchSocketHttpServerTransportSession(IServerTransportManager manager, st { _manager = manager; SessionId = sessionId; - _outgoingMessages = Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - }); } - /// - public IDisposable AttachRequestSseChannel(ChannelWriter writer) + /// + /// 将当前 POST 请求的 SSE 输出流绑定到此会话。 + /// 返回的 Dispose 后自动清除绑定。 + /// + internal IDisposable SetRequestSseStream(Stream stream) { - _requestSseWriter = writer; - return new RequestSseChannelRegistration(this); + _currentRequestSseStream = stream; + return new SseStreamScope(this); } - private void DetachRequestSseChannel() + private void ClearRequestSseStream() { - _requestSseWriter = null; - } - - /// - public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) - { - if (_disposeCts.IsCancellationRequested) - { - return Task.CompletedTask; - } - // 优先写入 per-request 通道(POST 响应 SSE 流);否则走全局 GET SSE 通道。 - var writer = _requestSseWriter; - if (writer is not null) - { - return writer.WriteAsync(message, cancellationToken).AsTask(); - } - return _outgoingMessages.Writer.WriteAsync(message, cancellationToken).AsTask(); + _currentRequestSseStream = null; } /// @@ -87,6 +66,9 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); } + var stream = _currentRequestSseStream + ?? throw new InvalidOperationException("当前没有绑定的 SSE 流,无法发送服务端主动请求。"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests[id] = tcs; @@ -100,8 +82,8 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc try { - // 通过 SSE 通道将请求发送给客户端(优先 per-request,否则 GET SSE)。 - await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + // 直接写入当前 POST 请求的 SSE 流,不经过 Channel。 + await WriteSseMessageAsync(stream, request, cancellationToken).ConfigureAwait(false); return await tcs.Task.ConfigureAwait(false); } finally @@ -124,63 +106,6 @@ public void HandleResponseAsync(JsonRpcResponse response) } } - /// - /// 运行 SSE 长连接,持续向客户端推送消息,直到连接断开或取消。 - /// - /// 用于向客户端写入 SSE 数据的输出流。 - /// 用于取消操作的令牌。 - public async Task RunSseConnectionAsync(Stream outputStream, CancellationToken cancellationToken) - { - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); - var ct = linkedCts.Token; - - try - { - Log.Debug($"[McpServer][TouchSocket] SSE connection started. SessionId={SessionId}"); - - // Wait for messages and write them - await foreach (var message in _outgoingMessages.Reader.ReadAllAsync(ct)) - { - await WriteSseMessageAsync(outputStream, message, ct); - } - } - catch (OperationCanceledException) - { - // Expected on shutdown - } - catch (Exception ex) - { - Log.Warn($"[McpServer][TouchSocket] SSE connection error. SessionId={SessionId}, Error={ex.Message}"); - } - finally - { - Log.Debug($"[McpServer][TouchSocket] SSE connection ended. SessionId={SessionId}"); - } - } - - /// - /// 运行 per-request SSE 流:持续消费 中的消息并写入 , - /// 直到 channel 完成(Complete)或取消。 - /// - public async Task RunRequestSseAsync(Channel channel, Stream outputStream, CancellationToken cancellationToken) - { - try - { - await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) - { - await WriteSseMessageAsync(outputStream, message, cancellationToken); - } - } - catch (OperationCanceledException) - { - // 正常取消 - } - catch (Exception ex) - { - Log.Warn($"[McpServer][TouchSocket] Per-request SSE error. SessionId={SessionId}, Error={ex.Message}"); - } - } - internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) { try @@ -221,7 +146,6 @@ public async ValueTask DisposeAsync() await Task.Yield(); _disposeCts.Cancel(); #endif - _outgoingMessages.Writer.TryComplete(); foreach (var (_, tcs) in _pendingRequests) { tcs.TrySetCanceled(); @@ -230,8 +154,8 @@ public async ValueTask DisposeAsync() _disposeCts.Dispose(); } - private sealed class RequestSseChannelRegistration(TouchSocketHttpServerTransportSession session) : IDisposable + private sealed class SseStreamScope(TouchSocketHttpServerTransportSession session) : IDisposable { - public void Dispose() => session.DetachRequestSseChannel(); + public void Dispose() => session.ClearRequestSseStream(); } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 9678403..d3c7180 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -2,7 +2,6 @@ using System.Net; using System.Text; using System.Text.Json; -using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; @@ -316,43 +315,25 @@ await _manager.HandleRequestAsync( // 非 initialize 请求:以 text/event-stream 响应,允许服务端在处理期间发起 sampling 等请求。 // 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending // the JSON-RPC response. These messages SHOULD relate to the originating client request." - var requestChannel = Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - }); - context.Response.StatusCode = (int)HttpStatusCode.OK; context.Response.ContentType = "text/event-stream"; context.Response.Headers["Cache-Control"] = "no-cache"; - using var channelRegistration = capturedSession.AttachRequestSseChannel(requestChannel.Writer); - - // 并发:(a) 处理请求,完成后把响应写入 Channel;(b) 消费 Channel,写入 SSE 流。 - var handleTask = Task.Run(async () => - { - try - { - var resp = await _manager.HandleRequestAsync(jsonRpcRequest, - s => s.AddTransportSession(capturedSession, Log), - cancellationToken); - if (resp != null) - { - await requestChannel.Writer.WriteAsync(resp, cancellationToken); - } - } - finally - { - requestChannel.Writer.TryComplete(); - } - }, cancellationToken); - var output = context.Response.OutputStream; await output.WriteAsync(PrimeEventBytes, cancellationToken); await output.FlushAsync(cancellationToken); - await capturedSession.RunRequestSseAsync(requestChannel, output, cancellationToken); - await handleTask; // 确保处理完成,传播异常 + // 绑定 SSE 流:HandleRequestAsync 执行期间,SendRequestAsync 直接向 output 写采样请求。 + using var _ = capturedSession.SetRequestSseStream(output); + + var response = await _manager.HandleRequestAsync(jsonRpcRequest, + s => s.AddTransportSession(capturedSession, Log), + cancellationToken); + + if (response != null) + { + await capturedSession.WriteSseMessageAsync(output, response, cancellationToken); + } context.Response.SafeClose(); } return; @@ -400,7 +381,12 @@ private async Task HandleGetRequestAsync(HttpListenerContext context, Cancellati await output.WriteAsync(PrimeEventBytes, cancellationToken); await output.FlushAsync(cancellationToken); - await session.RunSseConnectionAsync(output, cancellationToken); + // 保持连接,暂不主动推送;未来实现全局推送时在此扩展。 + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (OperationCanceledException) + { + // 正常关闭 } catch (Exception ex) { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs index 1f697bc..de12b11 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Text; -using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -17,16 +16,14 @@ internal class LocalHostHttpServerTransportSession : IServerTransportSession private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); private readonly IServerTransportManager _manager; - private readonly Channel _outgoingMessages; private readonly CancellationTokenSource _disposeCts = new(); private readonly ConcurrentDictionary> _pendingRequests = []; /// - /// 当前 POST 请求绑定的 per-request SSE 写入通道。 - /// 非 null 时,SendMessageAsync/SendRequestAsync 优先写入此通道(走 POST 响应 SSE 流)。 - /// null 时回退到 _outgoingMessages(走 GET SSE 流)。 + /// 当前 POST 请求绑定的 SSE 输出流。 + /// 非 null 时,SendRequestAsync 直接向此流写入采样请求。 /// - private volatile ChannelWriter? _requestSseWriter; + private volatile Stream? _currentRequestSseStream; private IMcpLogger Log => _manager.Context.Logger; @@ -39,38 +36,21 @@ public LocalHostHttpServerTransportSession(IServerTransportManager manager, stri { _manager = manager; SessionId = sessionId; - _outgoingMessages = Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - }); - } - - /// - public IDisposable AttachRequestSseChannel(ChannelWriter writer) - { - _requestSseWriter = writer; - return new RequestSseChannelRegistration(this); } - private void DetachRequestSseChannel() + /// + /// 将当前 POST 请求的 SSE 输出流绑定到此会话。 + /// 返回的 Dispose 后自动清除绑定(在 POST 请求处理完成后由 Transport 调用)。 + /// + internal IDisposable SetRequestSseStream(Stream stream) { - _requestSseWriter = null; + _currentRequestSseStream = stream; + return new SseStreamScope(this); } - public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + private void ClearRequestSseStream() { - if (_disposeCts.IsCancellationRequested) - { - return Task.CompletedTask; - } - // 优先写入 per-request 通道(POST 响应 SSE 流);否则走全局 GET SSE 通道。 - var writer = _requestSseWriter; - if (writer is not null) - { - return writer.WriteAsync(message, cancellationToken).AsTask(); - } - return _outgoingMessages.Writer.WriteAsync(message, cancellationToken).AsTask(); + _currentRequestSseStream = null; } /// @@ -81,6 +61,9 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); } + var stream = _currentRequestSseStream + ?? throw new InvalidOperationException("当前没有绑定的 SSE 流,无法发送服务端主动请求。"); + Log.Debug($"[McpServer][StreamableHttp] Sending server-initiated request. Method={request.Method}, Id={id}, SessionId={SessionId}"); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -96,8 +79,8 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc try { - // 通过 SSE 通道将请求发送给客户端(优先 per-request,否则 GET SSE)。 - await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + // 直接写入当前 POST 请求的 SSE 流,不经过 Channel。 + await WriteSseMessageAsync(stream, request, cancellationToken).ConfigureAwait(false); return await tcs.Task.ConfigureAwait(false); } finally @@ -125,58 +108,6 @@ public void HandleResponseAsync(JsonRpcResponse response) } } - public async Task RunSseConnectionAsync(Stream outputStream, CancellationToken cancellationToken) - { - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); - var ct = linkedCts.Token; - - try - { - Log.Debug($"[McpServer][StreamableHttp] SSE connection started. SessionId={SessionId}"); - - // Wait for messages and write them - await foreach (var message in _outgoingMessages.Reader.ReadAllAsync(ct)) - { - await WriteSseMessageAsync(outputStream, message, ct); - } - } - catch (OperationCanceledException) - { - // Expected on shutdown - } - catch (Exception ex) - { - Log.Warn($"[McpServer][StreamableHttp] SSE connection error. SessionId={SessionId}, Error={ex.Message}"); - } - finally - { - Log.Debug($"[McpServer][StreamableHttp] SSE connection ended. SessionId={SessionId}"); - } - } - - /// - /// 运行 per-request SSE 流:持续消费 中的消息并写入 , - /// 直到 channel 完成(Complete)或取消。 - /// - public async Task RunRequestSseAsync(Channel channel, Stream outputStream, CancellationToken cancellationToken) - { - try - { - await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) - { - await WriteSseMessageAsync(outputStream, message, cancellationToken); - } - } - catch (OperationCanceledException) - { - // 正常取消 - } - catch (Exception ex) - { - Log.Warn($"[McpServer][StreamableHttp] Per-request SSE error. SessionId={SessionId}, Error={ex.Message}"); - } - } - internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) { try @@ -227,7 +158,6 @@ public async ValueTask DisposeAsync() await Task.Yield(); _disposeCts.Cancel(); #endif - _outgoingMessages.Writer.TryComplete(); foreach (var (_, tcs) in _pendingRequests) { tcs.TrySetCanceled(); @@ -236,8 +166,8 @@ public async ValueTask DisposeAsync() _disposeCts.Dispose(); } - private sealed class RequestSseChannelRegistration(LocalHostHttpServerTransportSession session) : IDisposable + private sealed class SseStreamScope(LocalHostHttpServerTransportSession session) : IDisposable { - public void Dispose() => session.DetachRequestSseChannel(); + public void Dispose() => session.ClearRequestSseStream(); } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs index 844c456..d667666 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs @@ -1,5 +1,4 @@ -using System.Threading.Channels; -using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports; @@ -22,12 +21,6 @@ public interface IServerTransportSession : IAsyncDisposable ///
ClientCapabilities? ConnectedClientCapabilities { get; set; } - /// - /// 将消息发送给其他端(不期望响应)。
- /// Sends a message to the other side (no response expected). - ///
- Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default); - /// /// 向客户端发送 JSON-RPC 请求并等待响应。用于服务器主动发起的请求(如 sampling/createMessage)。
/// Sends a JSON-RPC request to the client and waits for the response. Used for server-initiated requests (e.g. sampling/createMessage). @@ -39,12 +32,4 @@ public interface IServerTransportSession : IAsyncDisposable /// Handles a JSON-RPC response received from the client (a reply to a server-initiated request). ///
void HandleResponseAsync(JsonRpcResponse response); - - /// - /// 为当前正在处理的 POST 请求注册一个专属的 SSE 写入通道。 - /// 注册后, 会优先将消息写入此通道, - /// 而非全局的 GET SSE 通道,从而让服务端主动请求(如采样)与触发它的工具调用使用同一条 SSE 流。 - /// Dispose 返回的对象可注销通道(恢复到全局 GET SSE)。 - /// - IDisposable AttachRequestSseChannel(ChannelWriter writer); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index 06e1fdc..4ebedb5 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using System.Text; using System.Text.Json; -using System.Threading.Channels; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages; @@ -128,13 +127,6 @@ public void HandleResponseAsync(JsonRpcResponse response) } } - /// - public IDisposable AttachRequestSseChannel(ChannelWriter writer) - { - // STDIO 传输层是全双工管道,不需要 per-request SSE 通道,此方法为空实现。 - return NopDisposable.Instance; - } - /// public ValueTask DisposeAsync() { @@ -155,9 +147,4 @@ public ValueTask DisposeAsync() _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), }; - private sealed class NopDisposable : IDisposable - { - public static readonly NopDisposable Instance = new(); - public void Dispose() { } - } } From c6292f621cbbae7c03c5b9396f3f73690bea881e Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 17:47:05 +0800 Subject: [PATCH 17/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E9=81=97=E7=95=99?= =?UTF-8?q?=E7=9A=84=E5=8F=8C=E8=AF=AD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9cdf4a5..75c1f12 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -42,7 +42,7 @@ src/DotNetCampus.ModelContextProtocol/ - 代码主要以最新版本协议进行编写 - 遇到需要兼容旧协议的部分,用 `Legacy` 命名相关代码并尽量减少代码量 - **协议消息类型规范**:详见 [/docs/knowledge/protocol-messages-guide.md](../docs/knowledge/protocol-messages-guide.md) - - **仅** `Protocol/` 文件夹下的消息类型必须添加中英双语注释;其他所有代码(接口、实现类、传输层等)一律使用**纯中文注释** + - **仅** `Protocol/` 文件夹下的消息类型必须添加中英双语注释;其他所有代码(接口、实现类、传输层等)一律使用**纯中文注释**(注:当前存在一些遗留非协议代码仍使用双语注释,如果改到了相关代码,请顺手改为纯中文注释) - 英文注释必须使用 MCP 官方 Schema 原文 - 当前使用协议版本:**2025-11-25** - Schema 文件:[schema.ts](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts) From 10002e62e732b3dd78337519cb856437cad36f9d Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 19:07:15 +0800 Subject: [PATCH 18/77] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BB=A5=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E4=BC=A0=E8=BE=93=E5=B1=82=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Ipc/IpcServerTransportSession.cs | 63 +--- .../TouchSocketHttpServerTransport.cs | 343 ++++++++++-------- .../TouchSocketHttpServerTransportSession.cs | 161 -------- ...ssion.cs => HttpServerTransportSession.cs} | 96 ++--- .../Http/LocalHostHttpServerTransport.cs | 287 ++++++++------- .../Transports/ServerTransportSession.cs | 103 ++++++ .../Stdio/StdioServerTransportSession.cs | 77 +--- 7 files changed, 525 insertions(+), 605 deletions(-) delete mode 100644 src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs rename src/DotNetCampus.ModelContextProtocol/Transports/Http/{LocalHostHttpServerTransportSession.cs => HttpServerTransportSession.cs} (51%) create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs index 7bc9d9c..447a6da 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs @@ -1,6 +1,4 @@ -using System.Collections.Concurrent; -using dotnetCampus.Ipc.Pipes; -using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using dotnetCampus.Ipc.Pipes; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Ipc; @@ -8,9 +6,8 @@ namespace DotNetCampus.ModelContextProtocol.Transports.Ipc; /// /// DotNetCampus.Ipc 传输层的一个会话。 /// -public class IpcServerTransportSession : IServerTransportSession +public class IpcServerTransportSession : ServerTransportSession { - private readonly ConcurrentDictionary> _pendingRequests = []; private PeerProxy? _peer; /// @@ -25,10 +22,7 @@ public IpcServerTransportSession(string sessionId) /// /// DotNetCampus.Ipc 传输层其实是严格一对一对应一个 的,所以其实不需要设置此属性。不过我们还是设了,调试稍微方便一点点。 /// - public string SessionId { get; } - - /// - public ClientCapabilities? ConnectedClientCapabilities { get; set; } + public override string SessionId { get; } /// /// 设置与此会话关联的 IPC 对端代理,用于 SendRequestAsync 发送消息。 @@ -39,57 +33,16 @@ internal void SetPeer(PeerProxy peer) } /// - public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) - { - if (request.Id?.ToString() is not { } id) - { - throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); - } - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingRequests[id] = tcs; - - using var registration = cancellationToken.Register(() => - { - if (_pendingRequests.TryRemove(id, out var removed)) - { - removed.TrySetCanceled(cancellationToken); - } - }); - - try - { - // IPC 传输层的服务端主动请求尚未实现。 - throw new NotImplementedException("IPC 传输层尚不支持服务端主动发起请求(如 sampling/createMessage)。"); - } - finally - { - _pendingRequests.TryRemove(id, out _); - } - } - - /// - public void HandleResponseAsync(JsonRpcResponse response) + protected override Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - if (response.Id?.ToString() is not { } id) - { - return; - } - - if (_pendingRequests.TryRemove(id, out var tcs)) - { - tcs.TrySetResult(response); - } + // IPC 传输层的服务端主动请求尚未实现。 + throw new NotImplementedException("IPC 传输层尚不支持服务端主动发起请求(如 sampling/createMessage)。"); } /// - public ValueTask DisposeAsync() + public override ValueTask DisposeAsync() { - foreach (var (_, tcs) in _pendingRequests) - { - tcs.TrySetCanceled(); - } - _pendingRequests.Clear(); + CancelAllPendingRequests(); return ValueTask.CompletedTask; } } diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 4831c23..154ecb3 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -13,6 +13,17 @@ namespace DotNetCampus.ModelContextProtocol.Transports.TouchSocket; +// 结构说明:本类的 POST 处理逻辑结构与 LocalHostHttpServerTransport 完全对称。 +// 方法对应关系: +// HandleStreamableHttpMessageAsync ↔ HandlePostRequestAsync +// HandleClientResponseAsync ↔ HandleClientResponseAsync +// HandleNotificationAsync ↔ HandleNotificationAsync +// HandleRpcRequestAsync ↔ HandleRpcRequestAsync +// GetOrCreateSessionAsync ↔ GetOrCreateSessionAsync +// HandleInitializeAsync ↔ HandleInitializeAsync +// HandleSseRequestAsync ↔ HandleSseRequestAsync +// 如需修改协议逻辑,请同时更新对应方法。 + /// /// 基于 TouchSocket.Http 的 Streamable HTTP 传输层实现。 /// @@ -28,7 +39,7 @@ public class TouchSocketHttpServerTransport : PluginBase, IHttpPlugin, IServerTr private readonly IServerTransportManager _manager; private readonly ITouchSocketHttpServerTransportOptions _options; - private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _sessions = new(); private readonly TouchSocketConfig? _config; private readonly HttpService? _httpService; @@ -251,25 +262,20 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca // 协议版本检查 var protocolVersion = request.Headers.Get(ProtocolVersionHeader).First; - if (!string.IsNullOrEmpty(protocolVersion)) + if (!string.IsNullOrEmpty(protocolVersion) && string.CompareOrdinal(protocolVersion, ProtocolVersion.Minimum) < 0) { - // 如果比最小版本小则报错 - if (string.CompareOrdinal(protocolVersion, ProtocolVersion.Minimum) < 0) - { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Unsupported protocol version. Version={protocolVersion}"); - await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.Minimum}"); - return; - } + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Unsupported protocol version. Version={protocolVersion}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.Minimum}"); + return; } var sessionIdStr = request.Headers.Get(SessionIdHeader).First; - // 将 body 直接传给 ReadMessageAsync,统一解析并分类消息类型。 - ReadOnlyMemory bodyBytes; + // 解析消息体 JsonRpcMessage? message; try { - bodyBytes = await request.GetContentAsync(); + var bodyBytes = await request.GetContentAsync(); message = await _manager.ReadMessageAsync(bodyBytes); } catch (JsonException) @@ -282,142 +288,14 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca switch (message) { case JsonRpcResponse jsonRpcResponse: - { - // 客户端响应服务器发起的请求(如 sampling/createMessage)。 - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) - { - Log.Warn($"[McpServer][TouchSocket] Response routing failed: Session not found. SessionId={sessionIdStr}"); - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; - } - responseSession.HandleResponseAsync(jsonRpcResponse); - await context.RespondHttpSuccess(HttpStatusCode.Accepted); + await HandleClientResponseAsync(context, sessionIdStr, jsonRpcResponse); return; - } - case JsonRpcNotification notification: - { - // 通知,无需响应。 - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var notificationSession)) - { - Log.Warn($"[McpServer][TouchSocket] Notification routing failed: Session not found. SessionId={sessionIdStr}"); - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; - } - var capturedNotificationSession = notificationSession; - await _manager.HandleRequestAsync( - new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, - s => - { - s.AddHttpTransportServices(capturedNotificationSession.SessionId, request); - s.AddTransportSession(capturedNotificationSession, Log); - }, - cancellationToken: cancellationToken); - await context.RespondHttpSuccess(HttpStatusCode.Accepted); + await HandleNotificationAsync(context, sessionIdStr, notification, request, cancellationToken); return; - } - case JsonRpcRequest jsonRpcRequest: - { - var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; - TouchSocketHttpServerTransportSession? session; - - if (isInitialize) - { - // 初始化请求,创建新 Session - var newSessionId = _manager.MakeNewSessionId(); - var newSession = new TouchSocketHttpServerTransportSession(_manager, newSessionId.Id); - - if (_sessions.TryAdd(newSessionId.Id, newSession)) - { - session = newSession; - _manager.Add(session); - context.Response.Headers.Add(SessionIdHeader, newSessionId.Id); - Log.Info($"[McpServer][TouchSocket] Session created. SessionId={newSessionId.Id}"); - } - else - { - Log.Error($"[McpServer][TouchSocket] Session ID collision. SessionId={newSessionId.Id}"); - await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); - return; - } - } - else - { - if (string.IsNullOrEmpty(sessionIdStr)) - { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header. Method={jsonRpcRequest.Method}"); - await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); - return; - } - - if (!_sessions.TryGetValue(sessionIdStr, out session)) - { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}, Method={jsonRpcRequest.Method}"); - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; - } - } - - Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - - if (isInitialize) - { - // initialize 请求:直接返回 application/json,不需要 SSE 流。 - var initResponse = await _manager.HandleRequestAsync(jsonRpcRequest, - s => - { - s.AddHttpTransportServices(session.SessionId, request); - s.AddTransportSession(session, Log); - }, - cancellationToken: cancellationToken); - - if (initResponse != null) - { - Log.Debug($"[McpServer][TouchSocket] Sending initialize response. SessionId={session.SessionId}, MessageId={jsonRpcRequest.Id}"); - await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, initResponse); - } - else - { - Log.Debug($"[McpServer][TouchSocket] No response for initialize notification. SessionId={session.SessionId}"); - await context.RespondHttpSuccess(HttpStatusCode.Accepted); - } - } - else - { - // 非 initialize 请求:以 text/event-stream 响应,允许服务端在处理期间发起 sampling 等请求。 - // 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending - // the JSON-RPC response. These messages SHOULD relate to the originating client request." - context.Response.SetStatus(HttpStatusCode.OK, ""); - context.Response.ContentType = "text/event-stream"; - context.Response.Headers.Add("Cache-Control", "no-cache"); - - context.Response.IsChunk = true; - await using var output = context.Response.CreateWriteStream(); - await output.WriteAsync(PrimeEventBytes, cancellationToken); - await output.FlushAsync(cancellationToken); - - // 绑定 SSE 流:HandleRequestAsync 执行期间,SendRequestAsync 直接向 output 写采样请求。 - using var _ = session.SetRequestSseStream(output); - - var resp = await _manager.HandleRequestAsync(jsonRpcRequest, - s => - { - s.AddHttpTransportServices(session.SessionId, request); - s.AddTransportSession(session, Log); - }, - cancellationToken: cancellationToken); - - if (resp != null) - { - Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response via SSE. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); - await session.WriteSseMessageAsync(output, resp, cancellationToken); - } - await context.Response.CompleteChunkAsync(cancellationToken); - } + await HandleRpcRequestAsync(context, sessionIdStr, jsonRpcRequest, request, cancellationToken); return; - } - default: Log.Warn($"[McpServer][TouchSocket] POST request rejected: Invalid or unrecognized JSON-RPC message."); await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); @@ -425,6 +303,185 @@ await _manager.HandleRequestAsync( } } + /// + /// 客户端响应服务器发起的请求(如 sampling/createMessage)。 + /// + /// + /// + /// + private async ValueTask HandleClientResponseAsync(HttpContext context, string? sessionIdStr, JsonRpcResponse response) + { + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + { + Log.Warn($"[McpServer][TouchSocket] Response routing failed: Session not found. SessionId={sessionIdStr}"); + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + session.HandleResponseAsync(response); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + + /// + /// 通知消息,无需响应。 + /// + /// + /// + /// + /// + /// + private async ValueTask HandleNotificationAsync(HttpContext context, string? sessionIdStr, JsonRpcNotification notification, HttpRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + { + Log.Warn($"[McpServer][TouchSocket] Notification routing failed: Session not found. SessionId={sessionIdStr}"); + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + + /// + /// JSON-RPC 请求(包含 initialize 和普通请求两种路径)。 + /// + /// + /// + /// + /// + /// + private async ValueTask HandleRpcRequestAsync(HttpContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest, HttpRequest request, CancellationToken cancellationToken) + { + var session = await GetOrCreateSessionAsync(context, sessionIdStr, jsonRpcRequest); + if (session is null) return; + + Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + + if (jsonRpcRequest.Method == RequestMethods.Initialize) + { + await HandleInitializeAsync(context, session, jsonRpcRequest, request, cancellationToken); + } + else + { + await HandleSseRequestAsync(context, session, jsonRpcRequest, request, cancellationToken); + } + } + + /// + /// 查找已有 Session 或为 initialize 请求创建新 Session。失败时向客户端写入错误响应并返回 null。 + /// + /// + /// + /// + /// + private async ValueTask GetOrCreateSessionAsync(HttpContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest) + { + if (jsonRpcRequest.Method == RequestMethods.Initialize) + { + var newSessionId = _manager.MakeNewSessionId(); + var newSession = new HttpServerTransportSession(_manager, newSessionId.Id, "[McpServer][TouchSocket]"); + if (_sessions.TryAdd(newSessionId.Id, newSession)) + { + _manager.Add(newSession); + context.Response.Headers.Add(SessionIdHeader, newSessionId.Id); + Log.Info($"[McpServer][TouchSocket] Session created. SessionId={newSessionId.Id}"); + return newSession; + } + Log.Error($"[McpServer][TouchSocket] Session ID collision. SessionId={newSessionId.Id}"); + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return null; + } + + if (string.IsNullOrEmpty(sessionIdStr)) + { + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header. Method={jsonRpcRequest.Method}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); + return null; + } + if (!_sessions.TryGetValue(sessionIdStr, out var session)) + { + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}, Method={jsonRpcRequest.Method}"); + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return null; + } + return session; + } + + /// + /// initialize 请求:同步返回 application/json,无需 SSE 流。 + /// + /// + /// + /// + /// + /// + private async ValueTask HandleInitializeAsync(HttpContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, HttpRequest request, CancellationToken cancellationToken) + { + var initResponse = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + + if (initResponse != null) + { + Log.Debug($"[McpServer][TouchSocket] Sending initialize response. SessionId={session.SessionId}, MessageId={jsonRpcRequest.Id}"); + await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, initResponse); + } + else + { + Log.Debug($"[McpServer][TouchSocket] No response for initialize notification. SessionId={session.SessionId}"); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + } + + /// + /// 非 initialize 请求:以 text/event-stream 响应,服务端可在处理期间通过 SSE 流发起采样请求。 + /// 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending + /// the JSON-RPC response. These messages SHOULD relate to the originating client request." + /// + /// + /// + /// + /// + /// + private async ValueTask HandleSseRequestAsync(HttpContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, HttpRequest request, CancellationToken cancellationToken) + { + context.Response.SetStatus(HttpStatusCode.OK, ""); + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.Add("Cache-Control", "no-cache"); + + context.Response.IsChunk = true; + await using var output = context.Response.CreateWriteStream(); + await output.WriteAsync(PrimeEventBytes, cancellationToken); + await output.FlushAsync(cancellationToken); + + using var _ = session.SetRequestSseStream(output); + + var resp = await _manager.HandleRequestAsync(jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + + if (resp != null) + { + Log.Debug($"[McpServer][TouchSocket] Sending JSON-RPC response via SSE. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); + await session.WriteSseMessageAsync(output, resp, cancellationToken); + } + await context.Response.CompleteChunkAsync(cancellationToken); + } + /// /// Streamable HTTP: 客户端关闭连接 (DELETE /mcp)。 /// diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs deleted file mode 100644 index 6c52562..0000000 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportSession.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System.Collections.Concurrent; -using DotNetCampus.ModelContextProtocol.Protocol.Messages; -using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; -using DotNetCampus.ModelContextProtocol.Hosting.Logging; - -namespace DotNetCampus.ModelContextProtocol.Transports.TouchSocket; - -/// -/// Streamable HTTP 传输层的一个会话。 -/// -public class TouchSocketHttpServerTransportSession : IServerTransportSession -{ - private static readonly ReadOnlyMemory EventMessageBytes = "event: message\n"u8.ToArray(); - private static readonly ReadOnlyMemory DataPrefixBytes = "data: "u8.ToArray(); - private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); - - private readonly IServerTransportManager _manager; - private readonly CancellationTokenSource _disposeCts = new(); - private readonly ConcurrentDictionary> _pendingRequests = []; - - /// - /// 当前 POST 请求绑定的 SSE 输出流。 - /// 非 null 时,SendRequestAsync 直接向此流写入采样请求。 - /// - private volatile Stream? _currentRequestSseStream; - - private IMcpLogger Log => _manager.Context.Logger; - - /// - public string SessionId { get; } - - /// - public ClientCapabilities? ConnectedClientCapabilities { get; set; } - - /// - /// 初始化 类的新实例。 - /// - /// 辅助管理 MCP 传输层的管理器。 - /// 唯一标识此会话的 ID。 - public TouchSocketHttpServerTransportSession(IServerTransportManager manager, string sessionId) - { - _manager = manager; - SessionId = sessionId; - } - - /// - /// 将当前 POST 请求的 SSE 输出流绑定到此会话。 - /// 返回的 Dispose 后自动清除绑定。 - /// - internal IDisposable SetRequestSseStream(Stream stream) - { - _currentRequestSseStream = stream; - return new SseStreamScope(this); - } - - private void ClearRequestSseStream() - { - _currentRequestSseStream = null; - } - - /// - public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) - { - if (request.Id?.ToString() is not { } id) - { - throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); - } - - var stream = _currentRequestSseStream - ?? throw new InvalidOperationException("当前没有绑定的 SSE 流,无法发送服务端主动请求。"); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingRequests[id] = tcs; - - using var registration = cancellationToken.Register(() => - { - if (_pendingRequests.TryRemove(id, out var removed)) - { - removed.TrySetCanceled(cancellationToken); - } - }); - - try - { - // 直接写入当前 POST 请求的 SSE 流,不经过 Channel。 - await WriteSseMessageAsync(stream, request, cancellationToken).ConfigureAwait(false); - return await tcs.Task.ConfigureAwait(false); - } - finally - { - _pendingRequests.TryRemove(id, out _); - } - } - - /// - public void HandleResponseAsync(JsonRpcResponse response) - { - if (response.Id?.ToString() is not { } id) - { - return; - } - - if (_pendingRequests.TryRemove(id, out var tcs)) - { - tcs.TrySetResult(response); - } - } - - internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) - { - try - { - // event: message - await stream.WriteAsync(EventMessageBytes, ct); - - // data: ... - await stream.WriteAsync(DataPrefixBytes, ct); - - // Serialize - await _manager.WriteMessageAsync(stream, message, ct); - - // \n\n (End of event) - await stream.WriteAsync(NewLineBytes, ct); - await stream.WriteAsync(NewLineBytes, ct); - - await stream.FlushAsync(ct); - } - catch (Exception ex) - { - Log.Error($"[McpServer][TouchSocket] Failed to write SSE message. SessionId={SessionId}", ex); - throw; // Re-throw to close connection if write fails - } - } - - /// - public async ValueTask DisposeAsync() - { - if (_disposeCts.IsCancellationRequested) - { - return; - } - -#if NET8_0_OR_GREATER - await _disposeCts.CancelAsync(); -#else - await Task.Yield(); - _disposeCts.Cancel(); -#endif - foreach (var (_, tcs) in _pendingRequests) - { - tcs.TrySetCanceled(); - } - _pendingRequests.Clear(); - _disposeCts.Dispose(); - } - - private sealed class SseStreamScope(TouchSocketHttpServerTransportSession session) : IDisposable - { - public void Dispose() => session.ClearRequestSseStream(); - } -} diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs similarity index 51% rename from src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs rename to src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs index de12b11..47f9e7e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Text; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages; @@ -8,16 +7,17 @@ namespace DotNetCampus.ModelContextProtocol.Transports.Http; /// /// Streamable HTTP 传输层的一个会话。 +/// 同时被 和 TouchSocket HTTP 传输层使用。 /// -internal class LocalHostHttpServerTransportSession : IServerTransportSession +public class HttpServerTransportSession : ServerTransportSession { private static readonly ReadOnlyMemory EventMessageBytes = "event: message\n"u8.ToArray(); private static readonly ReadOnlyMemory DataPrefixBytes = "data: "u8.ToArray(); private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); private readonly IServerTransportManager _manager; + private readonly string _logPrefix; private readonly CancellationTokenSource _disposeCts = new(); - private readonly ConcurrentDictionary> _pendingRequests = []; /// /// 当前 POST 请求绑定的 SSE 输出流。 @@ -27,22 +27,27 @@ internal class LocalHostHttpServerTransportSession : IServerTransportSession private IMcpLogger Log => _manager.Context.Logger; - public string SessionId { get; } - /// - public ClientCapabilities? ConnectedClientCapabilities { get; set; } + public override string SessionId { get; } - public LocalHostHttpServerTransportSession(IServerTransportManager manager, string sessionId) + /// + /// 初始化 类的新实例。 + /// + /// 辅助管理 MCP 传输层的管理器。 + /// 唯一标识此会话的 ID。 + /// 日志前缀,用于区分不同传输层实现(如 "[McpServer][StreamableHttp]")。 + public HttpServerTransportSession(IServerTransportManager manager, string sessionId, string logPrefix) { _manager = manager; SessionId = sessionId; + _logPrefix = logPrefix; } /// /// 将当前 POST 请求的 SSE 输出流绑定到此会话。 /// 返回的 Dispose 后自动清除绑定(在 POST 请求处理完成后由 Transport 调用)。 /// - internal IDisposable SetRequestSseStream(Stream stream) + public IDisposable SetRequestSseStream(Stream stream) { _currentRequestSseStream = stream; return new SseStreamScope(this); @@ -54,61 +59,33 @@ private void ClearRequestSseStream() } /// - public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + protected override async Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - if (request.Id?.ToString() is not { } id) - { - throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); - } - var stream = _currentRequestSseStream ?? throw new InvalidOperationException("当前没有绑定的 SSE 流,无法发送服务端主动请求。"); - Log.Debug($"[McpServer][StreamableHttp] Sending server-initiated request. Method={request.Method}, Id={id}, SessionId={SessionId}"); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingRequests[id] = tcs; - - using var registration = cancellationToken.Register(() => - { - if (_pendingRequests.TryRemove(id, out var removed)) - { - removed.TrySetCanceled(cancellationToken); - } - }); + Log.Debug($"{_logPrefix} Sending server-initiated request. Method={request.Method}, Id={request.Id}, SessionId={SessionId}"); - try - { - // 直接写入当前 POST 请求的 SSE 流,不经过 Channel。 - await WriteSseMessageAsync(stream, request, cancellationToken).ConfigureAwait(false); - return await tcs.Task.ConfigureAwait(false); - } - finally - { - _pendingRequests.TryRemove(id, out _); - } + // 直接写入当前 POST 请求的 SSE 流,不经过 Channel。 + await WriteSseMessageAsync(stream, request, cancellationToken).ConfigureAwait(false); } /// - public void HandleResponseAsync(JsonRpcResponse response) + protected override void OnResponseReceived(string id, JsonRpcResponse response) { - if (response.Id?.ToString() is not { } id) - { - return; - } + Log.Debug($"{_logPrefix} Received client response for pending request. Id={id}, SessionId={SessionId}"); + } - if (_pendingRequests.TryRemove(id, out var tcs)) - { - Log.Debug($"[McpServer][StreamableHttp] Received client response for pending request. Id={id}, SessionId={SessionId}"); - tcs.TrySetResult(response); - } - else - { - Log.Warn($"[McpServer][StreamableHttp] Received unmatched client response. Id={id}, SessionId={SessionId}"); - } + /// + protected override void OnUnmatchedResponse(string id, JsonRpcResponse response) + { + Log.Warn($"{_logPrefix} Received unmatched client response. Id={id}, SessionId={SessionId}"); } - internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) + /// + /// 将一条 JSON-RPC 消息写入 SSE 流。 + /// + public async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) { try { @@ -124,7 +101,7 @@ internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, using var ms = new MemoryStream(); await _manager.WriteMessageAsync(ms, message, ct); var json = Encoding.UTF8.GetString(ms.ToArray()); - Log.Debug($"[McpServer][StreamableHttp] → {json}"); + Log.Debug($"{_logPrefix} → {json}"); await stream.WriteAsync(ms.ToArray(), ct); } else @@ -140,12 +117,13 @@ internal async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, } catch (Exception ex) { - Log.Error($"[McpServer][StreamableHttp] Failed to write SSE message. SessionId={SessionId}", ex); - throw; // Re-throw to close connection if write fails + Log.Error($"{_logPrefix} Failed to write SSE message. SessionId={SessionId}", ex); + throw; } } - public async ValueTask DisposeAsync() + /// + public override async ValueTask DisposeAsync() { if (_disposeCts.IsCancellationRequested) { @@ -158,15 +136,11 @@ public async ValueTask DisposeAsync() await Task.Yield(); _disposeCts.Cancel(); #endif - foreach (var (_, tcs) in _pendingRequests) - { - tcs.TrySetCanceled(); - } - _pendingRequests.Clear(); + CancelAllPendingRequests(); _disposeCts.Dispose(); } - private sealed class SseStreamScope(LocalHostHttpServerTransportSession session) : IDisposable + private sealed class SseStreamScope(HttpServerTransportSession session) : IDisposable { public void Dispose() => session.ClearRequestSseStream(); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index d3c7180..1cca496 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -22,7 +22,7 @@ public class LocalHostHttpServerTransport : IServerTransport private readonly IServerTransportManager _manager; private readonly LocalHostHttpServerTransportOptions _options; private readonly HttpListener _listener = new(); - private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _sessions = new(); /// /// 初始化 类的新实例。 @@ -176,17 +176,13 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat // 协议版本检查 var protocolVersion = request.Headers[ProtocolVersionHeader]; - if (!string.IsNullOrEmpty(protocolVersion)) + if (!string.IsNullOrEmpty(protocolVersion) && protocolVersion < ProtocolVersion.Minimum) { - // 如果比最小版本小则报错 - if (protocolVersion < ProtocolVersion.Minimum) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.Minimum}"); - return; - } + await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.Minimum}"); + return; } - // 将 body 直接传给 ReadMessageAsync,统一解析并分类消息类型。 + // 解析消息体 JsonRpcMessage? message; try { @@ -203,8 +199,6 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat return; } - var sessionIdStr = request.Headers[SessionIdHeader]; - if (Log.IsEnabled(LoggingLevel.Debug) && message is not null) { using var ms = new MemoryStream(); @@ -212,137 +206,184 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat Log.Debug($"[McpServer][StreamableHttp] ← {Encoding.UTF8.GetString(ms.ToArray())}"); } + var sessionIdStr = request.Headers[SessionIdHeader]; + switch (message) { case JsonRpcResponse jsonRpcResponse: - { - // 客户端响应服务器发起的请求(如 sampling/createMessage)。 - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var responseSession)) - { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; - } - responseSession.HandleResponseAsync(jsonRpcResponse); - context.RespondHttpSuccess(HttpStatusCode.Accepted); + await HandleClientResponseAsync(context, sessionIdStr, jsonRpcResponse); return; - } - case JsonRpcNotification notification: - { - // 通知,无需响应。 - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var notificationSession)) - { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; - } - var capturedNotificationSession = notificationSession; - await _manager.HandleRequestAsync( - new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, - s => s.AddTransportSession(capturedNotificationSession, Log), - cancellationToken); - context.RespondHttpSuccess(HttpStatusCode.Accepted); + await HandleNotificationAsync(context, sessionIdStr, notification, cancellationToken); return; - } - case JsonRpcRequest jsonRpcRequest: - { - var isInitialize = jsonRpcRequest.Method == RequestMethods.Initialize; - LocalHostHttpServerTransportSession? session; + await HandleRpcRequestAsync(context, sessionIdStr, jsonRpcRequest, cancellationToken); + return; + default: + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); + return; + } + } - if (isInitialize) - { - // 初始化请求,创建新 Session - var newSessionId = _manager.MakeNewSessionId(); - var newSession = new LocalHostHttpServerTransportSession(_manager, newSessionId.Id); + /// + /// 客户端响应服务器发起的请求(如 sampling/createMessage)。 + /// + /// + /// + /// + private async Task HandleClientResponseAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcResponse response) + { + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + session.HandleResponseAsync(response); + context.RespondHttpSuccess(HttpStatusCode.Accepted); + } - if (_sessions.TryAdd(newSessionId.Id, newSession)) - { - session = newSession; - _manager.Add(session); - context.Response.AppendHeader(SessionIdHeader, newSessionId.Id); - } - else - { - await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); - return; - } - } - else - { - if (string.IsNullOrEmpty(sessionIdStr)) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); - return; - } + /// + /// 通知消息,无需响应。 + /// + /// + /// + /// + /// + private async Task HandleNotificationAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcNotification notification, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + s => s.AddTransportSession(session, Log), + cancellationToken); + context.RespondHttpSuccess(HttpStatusCode.Accepted); + } - if (!_sessions.TryGetValue(sessionIdStr, out session)) - { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); - return; - } - } + /// + /// JSON-RPC 请求(包含 initialize 和普通请求两种路径)。 + /// + /// + /// + /// + /// + private async Task HandleRpcRequestAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) + { + var session = await GetOrCreateSessionAsync(context, sessionIdStr, jsonRpcRequest); + if (session is null) return; - var capturedSession = session; + if (jsonRpcRequest.Method == RequestMethods.Initialize) + { + await HandleInitializeAsync(context, session, jsonRpcRequest, cancellationToken); + } + else + { + await HandleSseRequestAsync(context, session, jsonRpcRequest, cancellationToken); + } + } - if (isInitialize) - { - // initialize 请求:直接返回 application/json,不需要 SSE 流。 - var initResponse = await _manager.HandleRequestAsync(jsonRpcRequest, - s => s.AddTransportSession(capturedSession, Log), - cancellationToken); + /// + /// 查找已有 Session 或为 initialize 请求创建新 Session。失败时向客户端写入错误响应并返回 null。 + /// + /// + /// + /// + /// + private async Task GetOrCreateSessionAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest) + { + if (jsonRpcRequest.Method == RequestMethods.Initialize) + { + var newSessionId = _manager.MakeNewSessionId(); + var newSession = new HttpServerTransportSession(_manager, newSessionId.Id, "[McpServer][StreamableHttp]"); + if (_sessions.TryAdd(newSessionId.Id, newSession)) + { + _manager.Add(newSession); + context.Response.AppendHeader(SessionIdHeader, newSessionId.Id); + return newSession; + } + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return null; + } - if (initResponse != null) - { - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)HttpStatusCode.OK; - try - { - await _manager.WriteMessageAsync(context.Response.OutputStream, initResponse, cancellationToken); - context.Response.SafeClose(); - } - catch - { - // Ignore write errors - } - } - else - { - context.RespondHttpSuccess(HttpStatusCode.Accepted); - } - } - else - { - // 非 initialize 请求:以 text/event-stream 响应,允许服务端在处理期间发起 sampling 等请求。 - // 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending - // the JSON-RPC response. These messages SHOULD relate to the originating client request." - context.Response.StatusCode = (int)HttpStatusCode.OK; - context.Response.ContentType = "text/event-stream"; - context.Response.Headers["Cache-Control"] = "no-cache"; + if (string.IsNullOrEmpty(sessionIdStr)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); + return null; + } + if (!_sessions.TryGetValue(sessionIdStr, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return null; + } + return session; + } + + /// + /// initialize 请求:同步返回 application/json,无需 SSE 流。 + /// + /// + /// + /// + /// + private async Task HandleInitializeAsync(HttpListenerContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) + { + var initResponse = await _manager.HandleRequestAsync(jsonRpcRequest, + s => s.AddTransportSession(session, Log), + cancellationToken); - var output = context.Response.OutputStream; - await output.WriteAsync(PrimeEventBytes, cancellationToken); - await output.FlushAsync(cancellationToken); + if (initResponse != null) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.OK; + try + { + await _manager.WriteMessageAsync(context.Response.OutputStream, initResponse, cancellationToken); + context.Response.SafeClose(); + } + catch + { + // 忽略写入错误 + } + } + else + { + context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + } - // 绑定 SSE 流:HandleRequestAsync 执行期间,SendRequestAsync 直接向 output 写采样请求。 - using var _ = capturedSession.SetRequestSseStream(output); + /// + /// 非 initialize 请求:以 text/event-stream 响应,服务端可在处理期间通过 SSE 流发起采样请求。 + /// 规范 §2.1 规则 6:"The server MAY send JSON-RPC requests and notifications before sending + /// the JSON-RPC response. These messages SHOULD relate to the originating client request." + /// + /// + /// + /// + /// + private async Task HandleSseRequestAsync(HttpListenerContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) + { + context.Response.StatusCode = (int)HttpStatusCode.OK; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers["Cache-Control"] = "no-cache"; - var response = await _manager.HandleRequestAsync(jsonRpcRequest, - s => s.AddTransportSession(capturedSession, Log), - cancellationToken); + var output = context.Response.OutputStream; + await output.WriteAsync(PrimeEventBytes, cancellationToken); + await output.FlushAsync(cancellationToken); - if (response != null) - { - await capturedSession.WriteSseMessageAsync(output, response, cancellationToken); - } - context.Response.SafeClose(); - } - return; - } + using var _ = session.SetRequestSseStream(output); - default: - await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); - return; + var response = await _manager.HandleRequestAsync(jsonRpcRequest, + s => s.AddTransportSession(session, Log), + cancellationToken); + + if (response != null) + { + await session.WriteSseMessageAsync(output, response, cancellationToken); } + context.Response.SafeClose(); } private async Task HandleGetRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs new file mode 100644 index 0000000..2a1fbc3 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; + +namespace DotNetCampus.ModelContextProtocol.Transports; + +/// +/// 的抽象基类,封装了通用的"等待客户端响应"模式(TCS 字典 + CancellationToken 注册)。 +/// +/// 各传输层的 Session 继承本类,并实现 来完成各自的"发送"操作 +/// (如 Stdio 写 stdout、Streamable HTTP 写 SSE 流)。 +/// +/// +public abstract class ServerTransportSession : IServerTransportSession +{ + private readonly ConcurrentDictionary> _pendingRequests = []; + + /// + public abstract string? SessionId { get; } + + /// + public ClientCapabilities? ConnectedClientCapabilities { get; set; } + + /// + public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + { + if (request.Id?.ToString() is not { } id) + { + throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + using var registration = cancellationToken.Register(() => + { + if (_pendingRequests.TryRemove(id, out var removed)) + { + removed.TrySetCanceled(cancellationToken); + } + }); + + try + { + await SendRequestMessageAsync(request, cancellationToken).ConfigureAwait(false); + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pendingRequests.TryRemove(id, out _); + } + } + + /// + /// 执行实际的消息发送操作。由子类实现,负责将 写入各自的传输通道 + /// (如 Stdio 写 stdout、Streamable HTTP 写 SSE 流)。 + /// + protected abstract Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken); + + /// + public void HandleResponseAsync(JsonRpcResponse response) + { + if (response.Id?.ToString() is not { } id) + { + return; + } + + if (_pendingRequests.TryRemove(id, out var tcs)) + { + OnResponseReceived(id, response); + tcs.TrySetResult(response); + } + else + { + OnUnmatchedResponse(id, response); + } + } + + /// + /// 匹配的客户端响应到达时的回调(可用于日志)。默认为空实现。 + /// + protected virtual void OnResponseReceived(string id, JsonRpcResponse response) { } + + /// + /// 无法匹配的客户端响应到达时的回调(可用于日志)。默认为空实现。 + /// + protected virtual void OnUnmatchedResponse(string id, JsonRpcResponse response) { } + + /// + /// 取消所有待处理的挂起请求,供 调用。 + /// + protected void CancelAllPendingRequests() + { + foreach (var (_, tcs) in _pendingRequests) + { + tcs.TrySetCanceled(); + } + _pendingRequests.Clear(); + } + + /// + public abstract ValueTask DisposeAsync(); +} diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index 4ebedb5..63c0074 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Text; using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; @@ -11,10 +10,9 @@ namespace DotNetCampus.ModelContextProtocol.Transports.Stdio; /// /// STDIO 传输层的一个会话。 /// -public class StdioServerTransportSession : IServerTransportSession +public class StdioServerTransportSession : ServerTransportSession { private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); - private readonly ConcurrentDictionary> _pendingRequests = []; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly IMcpLogger _logger; private StreamWriter? _output; @@ -31,10 +29,7 @@ public StdioServerTransportSession(IMcpLogger logger) /// /// STDIO 传输层是专用的,不需要会话 ID。 /// - public string? SessionId => null; - - /// - public ClientCapabilities? ConnectedClientCapabilities { get; set; } + public override string? SessionId => null; /// /// 由 在启动后设置输出流。 @@ -45,7 +40,13 @@ internal void SetOutput(StreamWriter output) } /// - public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + protected override async Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + _logger.Debug($"[McpServer][Stdio] Sending server-initiated request. Method={request.Method}, Id={request.Id}, SessionId={SessionId}"); + await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + } + + private async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { if (_output is not { } output) { @@ -77,65 +78,18 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can } /// - public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) - { - if (request.Id?.ToString() is not { } id) - { - throw new InvalidOperationException("请求 ID 不能为 null。Request ID must not be null."); - } - - _logger.Debug($"[McpServer][Stdio] Sending server-initiated request. Method={request.Method}, Id={id}"); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingRequests[id] = tcs; - - using var registration = cancellationToken.Register(() => - { - if (_pendingRequests.TryRemove(id, out var removed)) - { - removed.TrySetCanceled(cancellationToken); - } - }); - - try - { - await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); - return await tcs.Task.ConfigureAwait(false); - } - finally - { - _pendingRequests.TryRemove(id, out _); - } - } + protected override void OnResponseReceived(string id, JsonRpcResponse response) + => _logger.Debug($"[McpServer][Stdio] Received client response for pending request. Id={id}"); /// - public void HandleResponseAsync(JsonRpcResponse response) - { - if (response.Id?.ToString() is not { } id) - { - return; - } - - if (_pendingRequests.TryRemove(id, out var tcs)) - { - _logger.Debug($"[McpServer][Stdio] Received client response for pending request. Id={id}"); - tcs.TrySetResult(response); - } - else - { - _logger.Warn($"[McpServer][Stdio] Received unmatched client response. Id={id}"); - } - } + protected override void OnUnmatchedResponse(string id, JsonRpcResponse response) + => _logger.Warn($"[McpServer][Stdio] Received unmatched client response. Id={id}"); /// - public ValueTask DisposeAsync() + public override ValueTask DisposeAsync() { _writeLock.Dispose(); - foreach (var (_, tcs) in _pendingRequests) - { - tcs.TrySetCanceled(); - } - _pendingRequests.Clear(); + CancelAllPendingRequests(); return ValueTask.CompletedTask; } @@ -146,5 +100,4 @@ public ValueTask DisposeAsync() JsonRpcNotification notification => McpInternalJsonContext.Default.JsonRpcNotification, _ => throw new ArgumentException($"不支持的消息类型:{message.GetType().FullName}."), }; - } From 128aa76f470e5be629fd08f00ec567c0ab4e603d Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 19:39:30 +0800 Subject: [PATCH 19/77] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 - docs/en/QuickStart.md | 1 - ...p-server-transport-implementation-guide.md | 56 +++++++++---------- docs/knowledge/http-transport-guide.md | 40 +++++++------ docs/knowledge/test-cases.md | 46 ++++++++++----- docs/zh-hans/QuickStart.md | 3 +- .../Servers/McpServerBuilder.cs | 2 +- 7 files changed, 84 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 3d0e753..88caa37 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ internal class Program .WithTool(() => new SampleTools2()) ) // Use Streamable HTTP transport, listening on http://localhost:5943/mcp - // Also compatible with SSE, listening on http://localhost:5943/mcp/sse .WithLocalHostHttp(5943, "mcp") // You can also use stdio (standard input/output) transport, which is recommended by the MCP protocol for all MCP servers // However, it's generally not recommended to enable both http and stdio simultaneously, diff --git a/docs/en/QuickStart.md b/docs/en/QuickStart.md index cdc77fd..66c486b 100644 --- a/docs/en/QuickStart.md +++ b/docs/en/QuickStart.md @@ -19,7 +19,6 @@ internal class Program .WithTool(() => new SampleTools2()) ) // Use Streamable HTTP transport, listening on http://localhost:5943/mcp - // Also compatible with SSE, listening on http://localhost:5943/mcp/sse .WithLocalHostHttp(5943, "mcp") // You can also use stdio (standard input/output) transport, which is recommended by the MCP protocol for all MCP servers // However, it's generally not recommended to enable both http and stdio simultaneously, diff --git a/docs/knowledge/http-server-transport-implementation-guide.md b/docs/knowledge/http-server-transport-implementation-guide.md index 1616fc5..f5c28bb 100644 --- a/docs/knowledge/http-server-transport-implementation-guide.md +++ b/docs/knowledge/http-server-transport-implementation-guide.md @@ -59,17 +59,20 @@ * 反序列化 Body 为 `JsonRpcMessage`。 * 将消息通过 `OnMessageReceived` 传递给上层 MCP Server 处理。 6. **响应写入**: - * **情况 1:上层有直接同步返回 (Response)**: + * **`initialize` 请求**: * 设置 `Content-Type: application/json`。 * 写入响应 JSON。 * 返回 `200 OK`。 - * **情况 2:上层无直接返回 (Notification) 或 异步处理**: - * 返回 `202 Accepted`。 - * 无 Body。 - * *高级情况:SSE 升级*(如果 POST 请求 accept SSE 且 Server 决定用 SSE 回复): - * 设置 `Content-Type: text/event-stream`。 - * 保持连接并在稍后推送 SSE Event。 - * *建议:简单起见,POST 尽量使用 application/json 回复,推送信道留给 GET SSE。* + * **`JsonRpcResponse`(客户端回弹采样结果)**: + * 返回 `202 Accepted`,无 Body。 + * **`JsonRpcNotification`(客户端发送的通知)**: + * 返回 `202 Accepted`,无 Body。 + * **所有其他 `JsonRpcRequest`(工具调用等)**: + * 设置 `Content-Type: text/event-stream`,建立本次请求的专属 SSE 流。 + * 先发送一个空注释事件(prime event)保活。 + * 将此 SSE 流绑定到当前 Session(供采样等服务端主动请求使用)。 + * 调用 `HandleRequestAsync` 处理请求(期间采样请求将写入此 SSE 流)。 + * 将最终响应写入 SSE 流,关闭流。 ### C. 处理 GET 请求 (SSE Subscription) @@ -84,17 +87,11 @@ * 设置响应 Header `Content-Type: text/event-stream`。 * 设置 `Cache-Control: no-cache`。 * 返回 `200 OK`(此时不要关闭 Response 流)。 -4. **注册发送通道**: - * 将当前 HTTP Response 流包装为一个 `IAsyncWriter` 或类似接口。 - * 注册到 Session 对象中,作为服务端向客户端推送消息的通道(Server-to-Client Messenger)。 - * **多连接共存策略**:MCP 协议规范 §2.3.1 明确指出 *“The client MAY remain connected to multiple SSE streams simultaneously.”*。因此,服务端**应支持**每个 Session 维护一个活跃连接列表,并将消息广播到所有连接(或仅主连接)。 - * *实现简化建议*:遵循协议精神,服务端应允许新连接加入而不强制断开旧连接。 -5. **发送 Prime Event**: - * 立即发送一个空事件 `event: message\ndata: \n\n` 或仅 `:\n\n` (Comment) 以保活。 - * 根据 SSE 规范,发送 `id` 字段以支持重连。 -6. **保持循环**: - * 进入 `await Task.Delay(-1)` 或等待 Session 关闭信号。 - * 在循环中捕获异常,如果连接断开,从 Session 中注销此通道。 +4. **发送 Prime Event**: + * 立即发送一个空注释 `:\n\n` 以保活连接。 +5. **保持循环**: + * 进入 `await Task.Delay(-1)` 等待,保持 SSE 连接存活(此水路用于未来扩展服务端主动推送,当前晨2不发送任何业务消息)。 + * 在循环中捕获异常,如果连接断开则正常退出。 ### D. 处理 DELETE 请求 (Session Termination) @@ -125,22 +122,25 @@ ## 4. 关键数据结构:Session Store -需要一个线程安全的 `ConcurrentDictionary`。 +需要一个线程安全的 `ConcurrentDictionary`。 -**`HttpServerSession` 类职责**: +**`HttpServerTransportSession` 类职责**: * 存储 Session ID。 -* 管理 SSE 发送通道(也就是当前挂着的那个 GET Response 流)。 -* 提供 `SendMessageAsync(JsonRpcMessage)` 方法:将消息序列化为 SSE 格式 (`event: message\ndata: {...}\n\n`) 并写入流。 +* 和待决服务端请求的 TCS 字典(继承自 `ServerTransportSession` 基类)。 +* 管理当前 POST 请求的专属 SSE 输出流(`_currentRequestSseStream`),这是采样等服务端主动请求的通道。 +* 提供 `WriteSseMessageAsync(Stream, JsonRpcMessage)` 方法:将消息序列化为 SSE 格式 (`event: message\ndata: {...}\n\n`) 并写入流。 ## 5. 错误处理 * **JSON 序列化错误**:返回 400。 * **内部异常**:返回 500,并在 Body 中包含(或不包含)JSON-RPC Error。 -## 6. 待办事项 (Checklist) +## 6. 实现状态 (Checklist) -* [ ] 移除旧版兼容代码 (`/mcp/sse`, `/mcp/messages` 路径处理)。 -* [ ] 确保 POST/GET/DELETE 共用同一个 Endpoint URL。 -* [ ] 实现 Session ID 的生成(初始化时)和校验(后续请求)。 -* [ ] 实现 SSE 的心跳或 Keep-Alive(如果底层不自动处理)。 +* [x] POST/GET/DELETE 共用同一个 Endpoint URL `/mcp`。 +* [x] Session ID 的生成(initialize 时)和校验(后续请求)。 +* [x] 非 initialize 的 POST 请求返回 `text/event-stream`,套接 sampling 等服务端主动请求通道。 +* [x] 初始化请求返回 `application/json`。 +* [x] SSE prime event 保活连接。 +* [ ] 旧版协议兼容 (`/mcp/sse`, `/mcp/messages`)(目前未实现)。 diff --git a/docs/knowledge/http-transport-guide.md b/docs/knowledge/http-transport-guide.md index 27766b0..47665bc 100644 --- a/docs/knowledge/http-transport-guide.md +++ b/docs/knowledge/http-transport-guide.md @@ -9,9 +9,9 @@ | **最新** | 2025-11-25 | Streamable HTTP | `/mcp` | `Mcp-Session-Id` header | ✅ 已支持 | | | 2025-06-18 | Streamable HTTP | `/mcp` | `Mcp-Session-Id` header | ✅ 已支持 | | **变更** | 2025-03-26 | Streamable HTTP | `/mcp` | `Mcp-Session-Id` header | ✅ 已支持 | -| **旧协议** | 2024-11-05 | HTTP+SSE | `/mcp/sse`, `/mcp/messages` | query string `sessionId` | ✅ 兼容 | +| **旧协议** | 2024-11-05 | HTTP+SSE | `/mcp/sse`, `/mcp/messages` | query string `sessionId` | ❌ 未实现 | -> **说明**: 2025-11-25、2025-06-18 和 2025-03-26 在传输层上完全兼容,我们的实现同时支持这些版本。 +> **说明**: 2025-11-25、2025-06-18 和 2025-03-26 在传输层上完全兼容,我们的实现同时支持这些版本。旧版 HTTP+SSE 协议(2024-11-05)目前未实现,如有需要请提 issue。 ## 🔑 关键区别 @@ -70,26 +70,30 @@ endpoint.Equals(EndPoint, StringComparison.OrdinalIgnoreCase) ## 📁 代码组织 -```csharp -#region 新协议实现 (Streamable HTTP - 2025-03-26+) -// HandleSseConnectionAsync() -// HandleJsonRpcRequestAsync() -// HandleDeleteSessionAsync() -#endregion - -#region 旧协议兼容 (HTTP+SSE - 2024-11-05) -// HandleLegacySseConnectionAsync() // 带 Legacy 前缀 -// HandleLegacyMessageRequestAsync() -#endregion +POST 处理逻辑被拆分为职责单一的方法(LocalHost 和 TouchSocket 两版结构完全对称): + ``` +HandlePostRequestAsync(入口) + ├── HandleClientResponseAsync // 客户端响应服务端采样请求(JsonRpcResponse) + ├── HandleNotificationAsync // 通知消息,返回 202 Accepted + └── HandleRpcRequestAsync // JSON-RPC 请求 + ├── GetOrCreateSessionAsync // Session 查找/创建 + ├── HandleInitializeAsync // initialize:返回 application/json + └── HandleSseRequestAsync // 其他请求:返回 text/event-stream SSE +``` + +> **POST 响应规则**: +> - `initialize` 请求 → `Content-Type: application/json`,直接返回 +> - 所有其他 JSON-RPC 请求 → `Content-Type: text/event-stream`, +> 采样等服务端发起的消息在此流上推送,最终响应也写入此流后关闭 ## ✅ 测试清单 -- [ ] 新协议:POST `/mcp` 返回 `Mcp-Session-Id` -- [ ] 新协议:GET `/mcp` 建立 Streamable HTTP 连接 -- [ ] 新协议:DELETE `/mcp` 成功终止会话 -- [ ] 旧协议:GET `/mcp/sse` 发送 endpoint 事件 -- [ ] 旧协议:POST `/mcp/messages?sessionId=xxx` 正常工作 +- [x] 新协议:POST `/mcp` initialize 返回 `Mcp-Session-Id`(`application/json`) +- [x] 新协议:POST `/mcp` 工具调用返回 `text/event-stream` SSE 流 +- [x] 新协议:GET `/mcp` 建立 SSE 保活连接 +- [x] 新协议:DELETE `/mcp` 成功终止会话 +- [x] 采样(Sampling):服务端通过 POST 响应 SSE 流发起采样请求,客户端 POST 回采样结果 - [ ] 路径大小写不敏感 - [ ] 会话不存在时 DELETE 返回 200 OK(幂等性) diff --git a/docs/knowledge/test-cases.md b/docs/knowledge/test-cases.md index e6d5de9..bf23430 100644 --- a/docs/knowledge/test-cases.md +++ b/docs/knowledge/test-cases.md @@ -89,12 +89,24 @@ | 状态 | 方法名 | 场景描述 | 预期行为 | | :---: | :--- | :--- | :--- | | ✅ | `Delete_TerminateSession` | `LocalHost`, `TouchSocket` | DELETE 请求成功终止会话,IsConnected 为 false | -| ⏳ | `Post_NoSessionId` | 不带 `sessionId` query 发送消息 | 返回 400 Bad Request 或相应错误 | -| ⏳ | `Sse_EndpointEvent` | 建立旧协议 SSE 连接 | 首先收到 `event: endpoint` 消息 | +| ⏳ | `Post_NoSessionId` | 不带 `Mcp-Session-Id` header 发送消息 | 返回 400/404 错误 | +| ⏳ | `Sse_EndpointEvent` | GET SSE 连接 | SSE 流成功建立并保活 | --- -## 3. 官方兼容性测试 (Compliance) +## 3.5 采样功能测试 (Sampling) + +**文件路径**: `tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs` +**目标**: 验证服务器向客户端发起 `sampling/createMessage` 请求的完整流程。 + +| 状态 | 方法名 | DataRow / 参数 | 预期行为 | +| :---: | :--- | :--- | :--- | +| ✅ | `ServerToolCanRequestSampling` | `LocalHost`, `TouchSocket` | 工具内调用 Sampling,客户端处理器被执行,返回结果正确 | +| ✅ | `IsSupportedIsFalseWhenClientHasNoCapability` | `LocalHost`, `TouchSocket` | 客户端未声明 Sampling 能力时 IsSupported 为 false | + +--- + +## 4. 官方兑容性测试 (Compliance) **文件路径**: `tests/DotNetCampus.ModelContextProtocol.Tests/Compliance/OfficialServerTests.cs` **目标**: 启动真正的 Node.js MCP Server 验证本库 Client。 @@ -109,9 +121,9 @@ --- -## 4. 已实现的辅助工具 +## 5. 已实现的辅助工具 -### 4.1 测试工具 (Test Tools) +### 5.1 测试工具 (Test Tools) **文件路径**: `tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/` | 文件 | 类名 | 工具方法 | 用途 | @@ -121,15 +133,18 @@ | `ExceptionTool.cs` | `ExceptionTool` | `ThrowError(string? message)`, `ThrowNested()` | 异常处理测试 | | `LongTextTool.cs` | `LongTextTool` | `Generate(int length)` | 大数据量测试 | | `SimpleTool.cs` | `SimpleTool` | `SayHello()` | 最简单的工具 | +| `StatefulCounterTool.cs` | `StatefulCounterTool` | `Increment()`, `GetCount()` | 有状态工具实例语义测试 | +| `InjectedConstructorTool.cs` | `InjectedConstructorTool` | 注入构造函数工具 | 依赖注入测试 | +| `SamplingTool.cs` | `SamplingTool` | `AskLlm(string message, ...)`, `CheckSamplingCapability(...)` | 服务端发起采样请求测试 | -### 4.2 测试资源 (Test Resources) +### 5.2 测试资源 (Test Resources) **文件路径**: `tests/DotNetCampus.ModelContextProtocol.Tests/McpResources/` | 文件 | 类名 | 资源方法 | 用途 | | :--- | :--- | :--- | :--- | | `SimpleResource.cs` | `SimpleResource` | `TextFile()`, `BinaryImage()`, `UserProfile(int userId)` | 基本资源访问测试 | -### 4.3 测试工厂 (Integration Factory) +### 5.3 测试工厂 (Integration Factory) **文件路径**: `tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs` | 方法 | 用途 | @@ -137,16 +152,18 @@ | `CreateSimpleHttpAsync(HttpTransportType)` | 创建仅包含 SimpleTool 的测试包 | | `CreateFullHttpAsync(HttpTransportType)` | 创建包含所有测试工具的测试包 | | `CreateFullHttpWithResourcesAsync(HttpTransportType)` | 创建包含工具和资源的完整测试包 | -| `CreateHttpCoreAsync(HttpTransportType, Action)` | 完全自定义的测试包创建 | +| `CreateTransientCounterHttpAsync(HttpTransportType)` | 创建仅包含 Transient 计数工具的测试包 | +| `CreateHttpAsync(HttpTransportType, Action<...>)` | 自定义工具的测试包 | +| `CreateHttpCoreAsync(HttpTransportType, Action, Action?)` | 完全自定义,支持同时配置服务端和客户端(如配置 Sampling Handler) | -### 4.4 JSON 序列化上下文 +### 5.4 JSON 序列化上下文 **文件路径**: `tests/DotNetCampus.ModelContextProtocol.Tests/McpTools/TestToolJsonContext.cs` 用于 AOT 兼容的复杂对象序列化,包含 `EchoUserInfo` 等类型的注册。 --- -## 5. 待开发的辅助工具 +## 6. 待开发的辅助工具 1. **Mock Transport** * `InProcessServerTransport` / `InProcessClientTransport` @@ -157,11 +174,12 @@ --- -## 6. 测试统计 +## 7. 测试统计 | 类别 | 通过 | 跳过 | 规划 | | :--- | :---: | :---: | :---: | | 核心功能测试 | 28 | 2 | 2 | -| 传输层测试 | 6 | 3 | 4 | -| 官方兼容性测试 | 0 | 3 | 0 | -| **总计** | **34** | **8** | **6** | +| 传输层测试 | 6 | 2 | 2 | +| 采样功能测试 | 4 | 0 | 0 | +| 官方兑容性测试 | 0 | 3 | 0 | +| **总计** | **38** | **7** | **4** | diff --git a/docs/zh-hans/QuickStart.md b/docs/zh-hans/QuickStart.md index d07a79a..9d00ba8 100644 --- a/docs/zh-hans/QuickStart.md +++ b/docs/zh-hans/QuickStart.md @@ -18,8 +18,7 @@ internal class Program .WithTool(() => new SampleTools()) .WithTool(() => new SampleTools2()) ) - // 传输层使用 Streamable HTTP,监听 http://localhost:5943/mcp, - // 传输层同时兼容 SSE,监听地址为 http://localhost:5943/mcp/sse + // 传输层使用 Streamable HTTP,监听 http://localhost:5943/mcp .WithLocalHostHttp(5943, "mcp") // 传输层也可使用 stdio(标准输入输出),这是 MCP 协议建议所有 MCP 服务器都支持的传输层 // 不过通常不建议同时启用 http 和 stdio,因为前者通常要求单例运行,后者则必须支持多实例运行 diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs index 7f9ca73..8ec5cb6 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs @@ -42,7 +42,7 @@ public McpServerBuilder WithStdio() /// MCP 服务器将监听 http://localhost:{port} 上的请求。 /// /// MCP 服务器将监听的路由端点,例如指定为 mcp 时,完整的 URL 为 http://localhost:{port}/mcp。
- /// 所有的 MCP 请求都将发送到该端点;除非客户端使用旧版本(2024-11-05)的 SSE 协议传输时,会自动改为使用 /mcp/sse 端点。
+ /// 所有的 MCP 请求都将发送到该端点。
/// 如果不指定,会使用默认的 /mcp 端点;如果希望监听根路径,请指定为空字符串 ""。 /// /// 用于链式调用的 MCP 服务器生成器。 From 59639874035969fa0d4e179de0ad1e896108d39c Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 19:48:42 +0800 Subject: [PATCH 20/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/Stdio/StdioServerTransportSession.cs | 2 +- .../TestMcpFactory.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index 63c0074..52da284 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -50,7 +50,7 @@ private async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ca { if (_output is not { } output) { - return; + throw new InvalidOperationException("STDIO 传输层尚未初始化输出流,无法发送服务端主动请求。"); } await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs index 77b9284..47a700d 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs @@ -175,11 +175,11 @@ public async ValueTask CreateHttpCoreAsync( mcpServer.EnableDebugMode(); await mcpServer.StartAsync(CancellationToken.None); - var mcpClient = new McpClientBuilder() + var mcpClientBuilder = new McpClientBuilder() .WithLogger(DefaultLogger) .WithHttp($"http://127.0.0.1:{port}/mcp"); - configureClient?.Invoke(mcpClient); - var builtClient = mcpClient.Build(); + configureClient?.Invoke(mcpClientBuilder); + var builtClient = mcpClientBuilder.Build(); return new McpTestingPackage(mcpServer, builtClient); } From 52670d8dde1a9ca365a6bc90dd6e1f4edd2f1c1d Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 20:10:01 +0800 Subject: [PATCH 21/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs | 6 +++--- .../Servers/McpServerSampling.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs index ff1de29..b43c306 100644 --- a/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs +++ b/samples/DotNetCampus.SampleMcpServer/McpTools/SamplingTool.cs @@ -11,16 +11,16 @@ public class SamplingTool /// 通过客户端的 LLM 进行采样,将 prompt 发送给客户端,获取 LLM 响应并返回。 /// 用于人工验证 sampling/createMessage 协议流程是否正常。 ///
+ /// MCP 工具上下文 /// 发送给 LLM 的提示词 /// 最大生成令牌数 /// 可选的系统提示词 - /// MCP 工具上下文 [McpServerTool] public async Task AskLlm( + IMcpServerCallToolContext context, string prompt, int maxTokens = 1024, - string? systemPrompt = null, - IMcpServerCallToolContext context = null!) + string? systemPrompt = null) { if (!context.Sampling.IsSupported) { diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs index dcaa96c..90b89f7 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerSampling.cs @@ -27,7 +27,7 @@ public interface IMcpServerSampling /// 采样请求参数。 /// 取消令牌。 /// LLM 生成的采样结果。 - /// 当客户端未声明 Sampling 能力时抛出。 + /// 当客户端未声明 Sampling 能力时抛出。可通过提前判断 来避免此异常。 /// 当采样请求被用户(人工审批)拒绝时抛出。 Task CreateMessageAsync(CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default); } From 4b18f12d1d79f2c57c4657b819ed7926853fdb81 Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 20:48:57 +0800 Subject: [PATCH 22/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/Ipc/IpcServerTransport.cs | 22 ++++++++++------ .../Ipc/IpcServerTransportSession.cs | 26 ++++++++++++++----- .../TouchSocketHttpServerTransport.cs | 13 ++++++---- .../Clients/McpClientBuilder.cs | 18 +++++-------- .../Transports/ClientTransportManager.cs | 8 ++++++ 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs index 392476f..803a5f5 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs @@ -15,13 +15,14 @@ public class IpcServerTransport : IServerTransport { // System.Runtime.InteropServices.MemoryMarshal.Read("Dncp.Mcp"u8).ToString("X") // 小端写入时,可在 IPC 传输序列中看到 Dncp.Mcp = DotNetCampus.ModelContextProtocol 的 ASCII 字符串。 - private const ulong McpIpcHeader = 0x70634D2E70636E44; + private const ulong McpIpcHeader = IpcServerTransportSession.McpIpcHeader; private readonly IServerTransportManager _manager; private readonly TaskCompletionSource _taskCompletionSource = new(); private readonly IpcProvider _server; private readonly bool _isExternalIpcProvider; private readonly ConcurrentDictionary _sessions = []; + private CancellationToken _runningCancellationToken; /// /// 初始化 类的新实例。 @@ -58,6 +59,7 @@ public Task StartAsync(CancellationToken startingCancellationToken, Cancel _server.StartServer(); _server.PeerConnected += OnPeerConnected; + _runningCancellationToken = runningCancellationToken; runningCancellationToken.Register(() => _taskCompletionSource.TrySetResult()); return Task.FromResult(_taskCompletionSource.Task); } @@ -77,7 +79,9 @@ public ValueTask DisposeAsync() private void OnPeerConnected(object? sender, PeerConnectedArgs e) { - _sessions[e.Peer.PeerName] = new IpcServerTransportSession(e.Peer.PeerName); + var session = new IpcServerTransportSession(_manager, e.Peer.PeerName); + session.SetPeer(e.Peer); + _sessions[e.Peer.PeerName] = session; e.Peer.PeerConnectionBroken += OnPeerConnectionBroken; e.Peer.PeerReconnected += OnPeerReconnected; e.Peer.MessageReceived += OnMessageReceived; @@ -92,7 +96,9 @@ private void OnPeerConnectionBroken(object? sender, IPeerConnectionBrokenArgs e) private void OnPeerReconnected(object? sender, IPeerReconnectedArgs e) { var peer = (PeerProxy)sender!; - _sessions[peer.PeerName] = new IpcServerTransportSession(peer.PeerName); + var session = new IpcServerTransportSession(_manager, peer.PeerName); + session.SetPeer(peer); + _sessions[peer.PeerName] = session; } private void OnMessageReceived(object? sender, IPeerMessageArgs e) @@ -144,19 +150,19 @@ private async Task HandleMessageAsync(PeerProxy peer, IpcMessage message) // 通知,路由到处理器,无需回复。 await _manager.HandleRequestAsync( new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, - null, CancellationToken.None); + null, _runningCancellationToken); return; case JsonRpcRequest request: { - var response2 = await _manager.HandleRequestAsync(request, null, CancellationToken.None); + var response2 = await _manager.HandleRequestAsync(request, null, _runningCancellationToken); if (response2 is null) { // 按照 MCP 协议规范,本次请求仅需响应而无需回复。 // 而 IPC 不需要响应。 return; } - await _manager.RespondJsonRpcAsync(peer, response2, CancellationToken.None); + await _manager.RespondJsonRpcAsync(peer, response2, _runningCancellationToken); return; } @@ -168,7 +174,7 @@ await _manager.HandleRequestAsync( Code = (int)JsonRpcErrorCode.InvalidRequest, Message = "Invalid request message.", }, - }, CancellationToken.None); + }, _runningCancellationToken); return; } } @@ -184,7 +190,7 @@ public async ValueTask RespondJsonRpcAsync(PeerProxy peer, JsonRpcResponse respo { using var ms = new MemoryStream(); await manager.WriteMessageAsync(ms, response, cancellationToken); - await peer.NotifyAsync(new IpcMessage("", new IpcMessageBody(ms.GetBuffer(), 0, (int)ms.Length))); + await peer.NotifyAsync(new IpcMessage("", new IpcMessageBody(ms.GetBuffer(), 0, (int)ms.Length), IpcServerTransportSession.McpIpcHeader)); } catch { diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs index 447a6da..f484089 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs @@ -1,4 +1,5 @@ -using dotnetCampus.Ipc.Pipes; +using dotnetCampus.Ipc.Messages; +using dotnetCampus.Ipc.Pipes; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Ipc; @@ -8,14 +9,21 @@ namespace DotNetCampus.ModelContextProtocol.Transports.Ipc; /// public class IpcServerTransportSession : ServerTransportSession { + // System.Runtime.InteropServices.MemoryMarshal.Read("Dncp.Mcp"u8).ToString("X") + // 小端写入时,可在 IPC 传输序列中看到 Dncp.Mcp = DotNetCampus.ModelContextProtocol 的 ASCII 字符串。 + internal const ulong McpIpcHeader = 0x70634D2E70636E44; + + private readonly IServerTransportManager _manager; private PeerProxy? _peer; /// /// 创建 DotNetCampus.Ipc 传输层的一个会话。 /// + /// /// 会话 Id。 - public IpcServerTransportSession(string sessionId) + public IpcServerTransportSession(IServerTransportManager manager, string sessionId) { + _manager = manager; SessionId = sessionId; } @@ -25,7 +33,7 @@ public IpcServerTransportSession(string sessionId) public override string SessionId { get; } /// - /// 设置与此会话关联的 IPC 对端代理,用于 SendRequestAsync 发送消息。 + /// 设置与此会话关联的 IPC 对端代理,供服务端主动请求发送时使用。 /// internal void SetPeer(PeerProxy peer) { @@ -33,10 +41,16 @@ internal void SetPeer(PeerProxy peer) } /// - protected override Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) + protected override async Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - // IPC 传输层的服务端主动请求尚未实现。 - throw new NotImplementedException("IPC 传输层尚不支持服务端主动发起请求(如 sampling/createMessage)。"); + if (_peer is not { } peer) + { + throw new InvalidOperationException("IPC 对端代理尚未设置,无法发送服务端主动请求。请确认 SetPeer 已在连接建立时被调用。"); + } + + using var ms = new MemoryStream(); + await _manager.WriteMessageAsync(ms, request, cancellationToken); + await peer.NotifyAsync(new IpcMessage("McpServer.SendMessage", new IpcMessageBody(ms.GetBuffer(), 0, (int)ms.Length), McpIpcHeader)); } /// diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 154ecb3..fa69f7d 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -40,6 +40,7 @@ public class TouchSocketHttpServerTransport : PluginBase, IHttpPlugin, IServerTr private readonly IServerTransportManager _manager; private readonly ITouchSocketHttpServerTransportOptions _options; private readonly ConcurrentDictionary _sessions = new(); + private CancellationToken _runningCancellationToken; private readonly TouchSocketConfig? _config; private readonly HttpService? _httpService; @@ -94,6 +95,7 @@ public async Task StartAsync(CancellationToken startingCancellationToken, $"[McpServer][TouchSocket] Transport started with external HttpServer, endpoint: {_options.EndPoint}"); } + _runningCancellationToken = runningCancellationToken; return Task.Delay(Timeout.Infinite, runningCancellationToken); } @@ -165,14 +167,14 @@ await context.Response // Streamable HTTP: 客户端建立连接。 if (method == "GET" && endpoint.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase)) { - await HandleStreamableHttpConnectionAsync(context, CancellationToken.None); + await HandleStreamableHttpConnectionAsync(context, _runningCancellationToken); return; } // Streamable HTTP: 客户端发送消息。 if (method == "POST" && endpoint.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase)) { - await HandleStreamableHttpMessageAsync(context, CancellationToken.None); + await HandleStreamableHttpMessageAsync(context, _runningCancellationToken); return; } @@ -434,7 +436,7 @@ private async ValueTask HandleInitializeAsync(HttpContext context, HttpServerTra if (initResponse != null) { Log.Debug($"[McpServer][TouchSocket] Sending initialize response. SessionId={session.SessionId}, MessageId={jsonRpcRequest.Id}"); - await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, initResponse); + await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, initResponse, cancellationToken); } else { @@ -585,7 +587,8 @@ file static class Extensions /// 服务端传输管理器。 /// HTTP 状态码。 /// JSON-RPC 响应对象。 - internal async ValueTask RespondJsonRpcAsync(IServerTransportManager manager, int statusCode, JsonRpcResponse response) + /// 取消令牌。 + internal async ValueTask RespondJsonRpcAsync(IServerTransportManager manager, int statusCode, JsonRpcResponse response, CancellationToken cancellationToken) { context.Response.ContentType = "application/json"; context.Response.SetStatus(statusCode, ""); @@ -593,7 +596,7 @@ internal async ValueTask RespondJsonRpcAsync(IServerTransportManager manager, in context.Response.IsChunk = true; await using (var stream = context.Response.CreateWriteStream()) { - await manager.WriteMessageAsync(stream, response, CancellationToken.None); + await manager.WriteMessageAsync(stream, response, cancellationToken); } await context.Response.CompleteChunkAsync(); } diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs index 0769760..e486d17 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs @@ -133,14 +133,11 @@ public McpClientBuilder WithCapabilities(ClientCapabilities capabilities) } /// - /// 配置 Sampling 处理器,使客户端支持服务器发起的 sampling/createMessage 请求。
- /// 调用此方法会自动在客户端能力中声明 Sampling 支持。
- /// Configures a handler for server-initiated sampling/createMessage requests. - /// Calling this method automatically declares Sampling capability in client capabilities. + /// 配置 Sampling 处理器,使客户端支持服务器发起的 sampling/createMessage 请求。 + /// 调用此方法会自动在客户端能力中声明 Sampling 支持。 ///
/// - /// 当服务器请求采样时的处理函数。接收 并返回
- /// Handler invoked when the server requests sampling. Receives and returns . + /// 当服务器请求采样时的处理函数。接收 并返回 。 /// /// 用于链式调用的 MCP 客户端生成器。 public McpClientBuilder WithSamplingHandler( @@ -155,14 +152,11 @@ public McpClientBuilder WithSamplingHandler( } /// - /// 配置 Sampling 处理器,使客户端支持服务器发起的 sampling/createMessage 请求。
- /// 调用此方法会自动在客户端能力中声明 Sampling 支持。
- /// Configures a handler for server-initiated sampling/createMessage requests. - /// Calling this method automatically declares Sampling capability in client capabilities. + /// 配置 Sampling 处理器,使客户端支持服务器发起的 sampling/createMessage 请求。 + /// 调用此方法会自动在客户端能力中声明 Sampling 支持。 ///
/// - /// 处理函数工厂,接收 以便从中获取所需服务。
- /// Handler factory that receives an for resolving dependencies. + /// 处理函数工厂,接收 以便从中获取所需服务。 /// /// 用于链式调用的 MCP 客户端生成器。 public McpClientBuilder WithSamplingHandler( diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 1dcb97f..a7d34f3 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -189,6 +189,14 @@ public async ValueTask SendRequestAsync(JsonRpcRequest request, var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests[id] = tcs; + using var registration = cancellationToken.Register(() => + { + if (_pendingRequests.TryRemove(id, out var removed)) + { + removed.TrySetCanceled(cancellationToken); + } + }); + try { await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); From 3933fbec2d0181893d5d4814639528c6936ebf5a Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 20:54:33 +0800 Subject: [PATCH 23/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../http-server-transport-implementation-guide.md | 2 +- docs/knowledge/test-cases.md | 4 ++-- .../McpSamplingNotSupportedException.cs | 6 +----- .../Exceptions/McpSamplingRejectedException.cs | 15 +++++---------- .../McpServiceCollectionTransportExtensions.cs | 13 +++++-------- .../Transports/IClientTransportManager.cs | 3 +-- .../Transports/IServerTransportSession.cs | 9 +++------ 7 files changed, 18 insertions(+), 34 deletions(-) diff --git a/docs/knowledge/http-server-transport-implementation-guide.md b/docs/knowledge/http-server-transport-implementation-guide.md index f5c28bb..22a9bed 100644 --- a/docs/knowledge/http-server-transport-implementation-guide.md +++ b/docs/knowledge/http-server-transport-implementation-guide.md @@ -90,7 +90,7 @@ 4. **发送 Prime Event**: * 立即发送一个空注释 `:\n\n` 以保活连接。 5. **保持循环**: - * 进入 `await Task.Delay(-1)` 等待,保持 SSE 连接存活(此水路用于未来扩展服务端主动推送,当前晨2不发送任何业务消息)。 + * 进入 `await Task.Delay(-1)` 等待,保持 SSE 连接存活(此通路用于未来扩展服务端主动推送,当前暂不发送任何业务消息)。 * 在循环中捕获异常,如果连接断开则正常退出。 ### D. 处理 DELETE 请求 (Session Termination) diff --git a/docs/knowledge/test-cases.md b/docs/knowledge/test-cases.md index bf23430..78881fb 100644 --- a/docs/knowledge/test-cases.md +++ b/docs/knowledge/test-cases.md @@ -106,7 +106,7 @@ --- -## 4. 官方兑容性测试 (Compliance) +## 4. 官方兼容性测试 (Compliance) **文件路径**: `tests/DotNetCampus.ModelContextProtocol.Tests/Compliance/OfficialServerTests.cs` **目标**: 启动真正的 Node.js MCP Server 验证本库 Client。 @@ -181,5 +181,5 @@ | 核心功能测试 | 28 | 2 | 2 | | 传输层测试 | 6 | 2 | 2 | | 采样功能测试 | 4 | 0 | 0 | -| 官方兑容性测试 | 0 | 3 | 0 | +| 官方兼容性测试 | 0 | 3 | 0 | | **总计** | **38** | **7** | **4** | diff --git a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs index e3e1f14..c2fd2b9 100644 --- a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs +++ b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingNotSupportedException.cs @@ -2,11 +2,7 @@ namespace DotNetCampus.ModelContextProtocol.Exceptions; /// /// 当连接的 MCP 客户端未声明对 Sampling 能力的支持,导致无法发起 sampling/createMessage 请求时引发的异常。
-/// 此异常表示客户端在能力协商阶段未声明 sampling 能力,而非代码使用错误。
-/// Exception thrown when the connected MCP client has not declared Sampling capability support, -/// preventing a sampling/createMessage request from being sent. -/// This indicates that the client did not declare the sampling capability during negotiation, -/// not a programming error. +/// 此异常表示客户端在能力协商阶段未声明 sampling 能力,而非代码使用错误。 ///
public class McpSamplingNotSupportedException : McpClientException { diff --git a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs index f4d377a..02aa33c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs +++ b/src/DotNetCampus.ModelContextProtocol/Exceptions/McpSamplingRejectedException.cs @@ -2,18 +2,15 @@ namespace DotNetCampus.ModelContextProtocol.Exceptions; /// /// 当 MCP 客户端的采样请求被用户(人工审批)拒绝时引发的异常。
-/// 根据 MCP 规范,客户端实现应提供人工审批机制(human-in-the-loop),允许用户在采样请求发送给 LLM 之前拒绝它。
-/// Exception thrown when a sampling request was rejected by the user (human-in-the-loop approval was denied). -/// Per the MCP specification, client implementations SHOULD provide a human-in-the-loop mechanism -/// that allows users to deny sampling requests before they are sent to an LLM. +/// 根据 MCP 规范,客户端实现应提供人工审批机制(human-in-the-loop),允许用户在采样请求发送给 LLM 之前拒绝它。 ///
public class McpSamplingRejectedException : McpClientException { /// /// 初始化 类的新实例。 /// - /// 来自客户端的 JSON-RPC 错误码。The JSON-RPC error code from the client. - /// 来自客户端的拒绝原因说明。The rejection reason message from the client. + /// 来自客户端的 JSON-RPC 错误码。 + /// 来自客户端的拒绝原因说明。 public McpSamplingRejectedException(int errorCode, string message) : base($"Sampling request was rejected: [{errorCode}] {message}") { @@ -22,14 +19,12 @@ public McpSamplingRejectedException(int errorCode, string message) } /// - /// 获取来自客户端的 JSON-RPC 错误码。
- /// Gets the JSON-RPC error code returned by the client. + /// 获取来自客户端的 JSON-RPC 错误码。 ///
public int ErrorCode { get; } /// - /// 获取来自客户端的拒绝原因说明。
- /// Gets the rejection reason message returned by the client. + /// 获取来自客户端的拒绝原因说明。 ///
public string RejectionMessage { get; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs b/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs index e1ae6be..6061a7b 100644 --- a/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs +++ b/src/DotNetCampus.ModelContextProtocol/Hosting/Services/McpServiceCollectionTransportExtensions.cs @@ -5,21 +5,18 @@ namespace DotNetCampus.ModelContextProtocol.Hosting.Services; /// -/// 提供向 注册传输层会话服务的扩展方法。
-/// Extension methods for registering transport session services into . +/// 提供向 注册传输层会话服务的扩展方法。 ///
public static class McpServiceCollectionTransportExtensions { /// /// 向 MCP 服务集合中注册传输层会话相关服务,包括 - ///
- /// Registers transport session services into the MCP service collection, - /// including and . + /// 。 ///
- /// MCP 服务集合。The MCP service collection. - /// 当前传输层会话实例。The current transport session instance. + /// MCP 服务集合。 + /// 当前传输层会话实例。 /// 日志记录器,传递给 Sampling 实现。 - /// 提供链式调用的服务集合。The service collection for chaining. + /// 提供链式调用的服务集合。 public static IMcpServiceCollection AddTransportSession(this IMcpServiceCollection services, IServerTransportSession session, IMcpLogger logger) { services.AddScoped(session); diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs index 5f7f256..a8cfbc1 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs @@ -79,8 +79,7 @@ public interface IClientTransportManager ValueTask HandleRespondAsync(JsonRpcResponse response, CancellationToken cancellationToken = default); /// - /// 提供给传输层调用。当传输层收到来自服务器的 JSON-RPC 请求时(如 sampling/createMessage),调用此方法可以将请求交给 MCP 客户端进行处理并回送响应。
- /// Called by the transport layer when a server-initiated JSON-RPC request is received (e.g. sampling/createMessage). + /// 提供给传输层调用。当传输层收到来自服务器的 JSON-RPC 请求时(如 sampling/createMessage),调用此方法可以将请求交给 MCP 客户端进行处理并回送响应。 ///
/// 从传输层解析出来的服务器发起的 JSON-RPC 请求。 /// 取消令牌。 diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs index d667666..ce3cccc 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs @@ -16,20 +16,17 @@ public interface IServerTransportSession : IAsyncDisposable string? SessionId { get; } /// - /// 连接的客户端所声明的客户端能力。在 Initialize 握手完成后设置。
- /// The client capabilities declared by the connected client. Set after the Initialize handshake completes. + /// 连接的客户端所声明的客户端能力。在 Initialize 握手完成后设置。 ///
ClientCapabilities? ConnectedClientCapabilities { get; set; } /// - /// 向客户端发送 JSON-RPC 请求并等待响应。用于服务器主动发起的请求(如 sampling/createMessage)。
- /// Sends a JSON-RPC request to the client and waits for the response. Used for server-initiated requests (e.g. sampling/createMessage). + /// 向客户端发送 JSON-RPC 请求并等待响应。用于服务器主动发起的请求(如 sampling/createMessage)。 ///
Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default); /// - /// 处理从客户端收到的 JSON-RPC 响应(对服务器发起的请求的回复)。
- /// Handles a JSON-RPC response received from the client (a reply to a server-initiated request). + /// 处理从客户端收到的 JSON-RPC 响应(对服务器发起的请求的回复)。 ///
void HandleResponseAsync(JsonRpcResponse response); } From 19a5117ab39c9217fcb5baddf096b1e8f319fa4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:03:57 +0000 Subject: [PATCH 24/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81=EF=BC=9A=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E3=80=81=E9=87=8D=E5=A4=8D=E8=AF=B7=E6=B1=82?= =?UTF-8?q?ID=E3=80=81SSE=E5=BF=83=E8=B7=B3=E3=80=81=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E8=AF=B7=E6=B1=82=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E7=AD=89=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/dotnet-campus/DotNetCampus.ModelContextProtocol/sessions/40500a8e-81d2-431a-bb97-f6143ffa1a4f Co-authored-by: walterlv <9959623+walterlv@users.noreply.github.com> --- .../TouchSocket/TouchSocketHttpServerTransport.cs | 12 ++++++++++-- .../Transports/Http/HttpServerTransportSession.cs | 13 +++++++------ .../Transports/Http/LocalHostHttpServerTransport.cs | 12 ++++++++++-- .../Transports/ServerTransportManager.cs | 7 +++++-- .../Transports/ServerTransportSession.cs | 5 ++++- .../Servers/SamplingTests.cs | 1 - 6 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index fa69f7d..5fe796a 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -35,7 +35,9 @@ public class TouchSocketHttpServerTransport : PluginBase, IHttpPlugin, IServerTr { private const string ProtocolVersionHeader = "MCP-Protocol-Version"; private const string SessionIdHeader = "Mcp-Session-Id"; + private const int SseKeepAliveIntervalMs = 15000; private static readonly ReadOnlyMemory PrimeEventBytes = ": \n\n"u8.ToArray(); + private static readonly ReadOnlyMemory SseKeepAliveBytes = ": keep-alive\n\n"u8.ToArray(); private readonly IServerTransportManager _manager; private readonly ITouchSocketHttpServerTransportOptions _options; @@ -238,8 +240,14 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, await output.WriteAsync(PrimeEventBytes, cancellationToken); await output.FlushAsync(cancellationToken); - // 保持连接,暂不主动推送;未来实现全局推送时在此扩展。 - await Task.Delay(Timeout.Infinite, cancellationToken); + // 定期发送 SSE 心跳,以便在客户端断开时通过写入/刷新失败尽快退出, + // 避免仅依赖外部 cancellationToken 导致连接长期悬挂。 + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(SseKeepAliveIntervalMs, cancellationToken); + await output.WriteAsync(SseKeepAliveBytes, cancellationToken); + await output.FlushAsync(cancellationToken); + } } catch (OperationCanceledException) { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs index 47f9e7e..dda9508 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs @@ -23,7 +23,7 @@ public class HttpServerTransportSession : ServerTransportSession /// 当前 POST 请求绑定的 SSE 输出流。 /// 非 null 时,SendRequestAsync 直接向此流写入采样请求。 ///
- private volatile Stream? _currentRequestSseStream; + private Stream? _currentRequestSseStream; private IMcpLogger Log => _manager.Context.Logger; @@ -50,12 +50,13 @@ public HttpServerTransportSession(IServerTransportManager manager, string sessio public IDisposable SetRequestSseStream(Stream stream) { _currentRequestSseStream = stream; - return new SseStreamScope(this); + return new SseStreamScope(this, stream); } - private void ClearRequestSseStream() + private void ClearRequestSseStream(Stream stream) { - _currentRequestSseStream = null; + // 仅在字段仍指向本次绑定的 stream 时才清除,避免并发请求相互覆盖。 + Interlocked.CompareExchange(ref _currentRequestSseStream, null, stream); } /// @@ -140,8 +141,8 @@ public override async ValueTask DisposeAsync() _disposeCts.Dispose(); } - private sealed class SseStreamScope(HttpServerTransportSession session) : IDisposable + private sealed class SseStreamScope(HttpServerTransportSession session, Stream stream) : IDisposable { - public void Dispose() => session.ClearRequestSseStream(); + public void Dispose() => session.ClearRequestSseStream(stream); } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 1cca496..5f865c0 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -17,7 +17,9 @@ public class LocalHostHttpServerTransport : IServerTransport { private const string ProtocolVersionHeader = "MCP-Protocol-Version"; private const string SessionIdHeader = "Mcp-Session-Id"; + private const int SseKeepAliveIntervalMs = 15000; private static readonly ReadOnlyMemory PrimeEventBytes = ": \n\n"u8.ToArray(); + private static readonly ReadOnlyMemory SseKeepAliveBytes = ": keep-alive\n\n"u8.ToArray(); private readonly IServerTransportManager _manager; private readonly LocalHostHttpServerTransportOptions _options; @@ -422,8 +424,14 @@ private async Task HandleGetRequestAsync(HttpListenerContext context, Cancellati await output.WriteAsync(PrimeEventBytes, cancellationToken); await output.FlushAsync(cancellationToken); - // 保持连接,暂不主动推送;未来实现全局推送时在此扩展。 - await Task.Delay(Timeout.Infinite, cancellationToken); + // 定期发送 SSE 心跳,以便在客户端断开时通过写入/刷新失败尽快退出, + // 避免仅依赖外部 cancellationToken 导致连接长期悬挂。 + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(SseKeepAliveIntervalMs, cancellationToken); + await output.WriteAsync(SseKeepAliveBytes, cancellationToken); + await output.FlushAsync(cancellationToken); + } } catch (OperationCanceledException) { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index d2aaa30..d680104 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -159,7 +159,7 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio ///
private JsonRpcMessage? ClassifyAndDeserialize(JsonElement element) { - var hasMethod = element.TryGetProperty("method", out _); + var hasMethod = element.TryGetProperty("method", out var methodElement); if (hasMethod) { @@ -167,7 +167,10 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio var hasId = element.TryGetProperty("id", out var idElement) && idElement.ValueKind != JsonValueKind.Null; - if (hasId) + // initialize 请求即使 id 缺失或为 null 也应被视为请求(兼容旧客户端)。 + var isInitialize = methodElement.GetString() == RequestMethods.Initialize; + + if (hasId || isInitialize) { var request = element.Deserialize(McpInternalJsonContext.Default.JsonRpcRequest); if (request is { Method: RequestMethods.Initialize, Id: null }) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs index 2a1fbc3..fdd13ca 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs @@ -30,7 +30,10 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc } var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingRequests[id] = tcs; + if (!_pendingRequests.TryAdd(id, tcs)) + { + throw new InvalidOperationException($"已存在相同 ID 的挂起请求:{id}。"); + } using var registration = cancellationToken.Register(() => { diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs index a615710..c9dbe60 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Servers/SamplingTests.cs @@ -63,7 +63,6 @@ public async Task IsSupportedIsFalseWhenClientHasNoCapability(HttpTransportType configureBuilder: builder => builder.WithTools(t => t.WithTool(() => new SamplingTool()))); // Act - var toolArgs = JsonSerializer.SerializeToElement(new { }); var callResult = await package.Client.CallToolAsync("check_sampling_capability"); // Assert From c96610fa6a3016dd92bb67938e67802141046020 Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 21:09:17 +0800 Subject: [PATCH 25/77] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/TouchSocket/TouchSocketHttpServerTransport.cs | 2 +- .../Transports/Http/LocalHostHttpServerTransport.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 5fe796a..d6f1555 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -35,7 +35,7 @@ public class TouchSocketHttpServerTransport : PluginBase, IHttpPlugin, IServerTr { private const string ProtocolVersionHeader = "MCP-Protocol-Version"; private const string SessionIdHeader = "Mcp-Session-Id"; - private const int SseKeepAliveIntervalMs = 15000; + private const int SseKeepAliveIntervalMs = 60000; private static readonly ReadOnlyMemory PrimeEventBytes = ": \n\n"u8.ToArray(); private static readonly ReadOnlyMemory SseKeepAliveBytes = ": keep-alive\n\n"u8.ToArray(); diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 5f865c0..3ed1869 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -17,7 +17,7 @@ public class LocalHostHttpServerTransport : IServerTransport { private const string ProtocolVersionHeader = "MCP-Protocol-Version"; private const string SessionIdHeader = "Mcp-Session-Id"; - private const int SseKeepAliveIntervalMs = 15000; + private const int SseKeepAliveIntervalMs = 60000; private static readonly ReadOnlyMemory PrimeEventBytes = ": \n\n"u8.ToArray(); private static readonly ReadOnlyMemory SseKeepAliveBytes = ": keep-alive\n\n"u8.ToArray(); From 9888474def3022a40565868ed78787f295ade6e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:31:35 +0000 Subject: [PATCH 26/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81=EF=BC=9A=E5=8D=8F=E8=AE=AE=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=BF=90=E7=AE=97=E7=AC=A6=E6=AF=94=E8=BE=83=E3=80=81SSE?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=86=99=E4=BA=92=E6=96=A5=E3=80=81=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=AF=B7=E6=B1=82ID=E3=80=81=E5=8F=8C=E8=AF=AD?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E3=80=81ToArray=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/dotnet-campus/DotNetCampus.ModelContextProtocol/sessions/d511799d-394c-4862-8313-af504b9ea862 Co-authored-by: walterlv <9959623+walterlv@users.noreply.github.com> --- .../TouchSocket/TouchSocketHttpServerTransport.cs | 2 +- .../Servers/IMcpServerPrimitiveContext.cs | 4 +--- .../Transports/ClientTransportManager.cs | 5 ++++- .../Transports/Http/HttpServerTransportSession.cs | 12 ++++++++++-- .../Transports/Stdio/StdioServerTransportSession.cs | 5 +++-- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index d6f1555..339bbdf 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -272,7 +272,7 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca // 协议版本检查 var protocolVersion = request.Headers.Get(ProtocolVersionHeader).First; - if (!string.IsNullOrEmpty(protocolVersion) && string.CompareOrdinal(protocolVersion, ProtocolVersion.Minimum) < 0) + if (!string.IsNullOrEmpty(protocolVersion) && (ProtocolVersion)protocolVersion < ProtocolVersion.Minimum) { Log.Warn($"[McpServer][TouchSocket] POST request rejected: Unsupported protocol version. Version={protocolVersion}"); await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.Minimum}"); diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs index 41c3152..2d58385 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerPrimitiveContext.cs @@ -63,9 +63,7 @@ public interface IMcpServerCallToolContext : IMcpServerPrimitiveContext CancellationToken CancellationToken { get; } /// - /// 提供服务器向客户端发起 Sampling 请求的能力。始终非空;当传输层或客户端不支持 Sampling 时,
- /// Provides the ability to send Sampling requests from the server to the client. Always non-null; - /// when the transport or client does not support Sampling, will be . + /// 提供服务器向客户端发起 Sampling 请求的能力。始终非空;当传输层或客户端不支持 Sampling 时,。 ///
IMcpServerSampling Sampling { get; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index a7d34f3..71bfbe9 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -187,7 +187,10 @@ public async ValueTask SendRequestAsync(JsonRpcRequest request, } var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingRequests[id] = tcs; + if (!_pendingRequests.TryAdd(id, tcs)) + { + throw new InvalidOperationException($"已存在相同 ID 的挂起请求:{id}。"); + } using var registration = cancellationToken.Register(() => { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs index dda9508..3156ea1 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs @@ -18,6 +18,7 @@ public class HttpServerTransportSession : ServerTransportSession private readonly IServerTransportManager _manager; private readonly string _logPrefix; private readonly CancellationTokenSource _disposeCts = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); /// /// 当前 POST 请求绑定的 SSE 输出流。 @@ -88,6 +89,7 @@ protected override void OnUnmatchedResponse(string id, JsonRpcResponse response) /// public async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken ct) { + await _writeLock.WaitAsync(ct).ConfigureAwait(false); try { // event: message @@ -101,9 +103,10 @@ public async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, Ca { using var ms = new MemoryStream(); await _manager.WriteMessageAsync(ms, message, ct); - var json = Encoding.UTF8.GetString(ms.ToArray()); + var bytes = ms.ToArray(); + var json = Encoding.UTF8.GetString(bytes); Log.Debug($"{_logPrefix} → {json}"); - await stream.WriteAsync(ms.ToArray(), ct); + await stream.WriteAsync(bytes, ct); } else { @@ -121,6 +124,10 @@ public async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, Ca Log.Error($"{_logPrefix} Failed to write SSE message. SessionId={SessionId}", ex); throw; } + finally + { + _writeLock.Release(); + } } /// @@ -139,6 +146,7 @@ public override async ValueTask DisposeAsync() #endif CancelAllPendingRequests(); _disposeCts.Dispose(); + _writeLock.Dispose(); } private sealed class SseStreamScope(HttpServerTransportSession session, Stream stream) : IDisposable diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index 52da284..856f56c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -60,9 +60,10 @@ private async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ca { using var ms = new MemoryStream(); await JsonSerializer.SerializeAsync(ms, message, GetTypeInfo(message), cancellationToken).ConfigureAwait(false); - var json = Encoding.UTF8.GetString(ms.ToArray()); + var bytes = ms.ToArray(); + var json = Encoding.UTF8.GetString(bytes); _logger.Debug($"[McpServer][Stdio] → {json}"); - await output.BaseStream.WriteAsync(ms.ToArray(), cancellationToken).ConfigureAwait(false); + await output.BaseStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); } else { From 11e92fb39a50ff39f8a25cf6dd41fb1a90af4f2e Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 8 Apr 2026 22:08:06 +0800 Subject: [PATCH 27/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/ClientTransportManager.cs | 25 ++++++ .../Transports/Http/HttpClientTransport.cs | 68 ++++++---------- .../Transports/IClientTransportManager.cs | 17 ++++ .../Transports/Stdio/StdioClientTransport.cs | 77 ++++--------------- 4 files changed, 81 insertions(+), 106 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 71bfbe9..c7c864e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -45,6 +45,31 @@ public RequestId MakeNewRequestId() return RequestId.MakeNew(); } + /// + public ValueTask ReadMessageAsync(string messageLine) + { + using var doc = JsonDocument.Parse(messageLine); + return ValueTask.FromResult(ClassifyAndDeserialize(doc.RootElement)); + } + + /// + /// 根据 JSON-RPC 2.0 字段特征将 分类并反序列化为具体消息类型。 + /// + private static JsonRpcMessage? ClassifyAndDeserialize(JsonElement element) + { + if (element.TryGetProperty("method", out _)) + { + return element.Deserialize(McpInternalJsonContext.Default.JsonRpcRequest); + } + + if (element.TryGetProperty("result", out _) || element.TryGetProperty("error", out _)) + { + return element.Deserialize(McpInternalJsonContext.Default.JsonRpcResponse); + } + + return null; + } + /// public ValueTask ReadResponseAsync(string responseLine) { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index 28b8172..0fb8eb2 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -182,9 +182,9 @@ private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, Cancellatio await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); await ProcessSseStreamAsync(stream, cancellationToken, isInitialize - ? (json) => + ? (msg) => { - if (json.TryGetProperty("result", out var resultElement)) + if (msg is JsonRpcResponse { Result: { ValueKind: JsonValueKind.Object } resultElement }) { _protocolVersion = TryExtractProtocolVersion(resultElement, "SSE"); } @@ -331,7 +331,7 @@ private async Task ReceiveLoopAsync(CancellationToken token) // --- SSE 解析核心逻辑 --- - private async Task ProcessSseStreamAsync(Stream stream, CancellationToken token, Action? messageInspector = null) + private async Task ProcessSseStreamAsync(Stream stream, CancellationToken token, Action? messageInspector = null) { using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); @@ -372,7 +372,7 @@ private async Task ProcessSseStreamAsync(Stream stream, CancellationToken token, } } - private async Task DispatchSseEventAsync(string? eventName, string data, CancellationToken token, Action? messageInspector) + private async Task DispatchSseEventAsync(string? eventName, string data, CancellationToken token, Action? messageInspector) { if (string.IsNullOrEmpty(data) || data == "[DONE]") return; @@ -382,48 +382,35 @@ private async Task DispatchSseEventAsync(string? eventName, string data, Cancell try { - // 先尝试用 JsonDocument 解析来执行检查器,因为 _manager.ReadResponseAsync 会直接反序列化为对象, - // 而我们需要 inspect 具体字段(如 protocolVersion) - if (messageInspector != null) - { - try - { - using var doc = JsonDocument.Parse(data); - messageInspector(doc.RootElement); - } - catch - { - // 忽略解析错误,后续 _manager 会处理 - } - } - - // 检测是服务器主动发起的请求(有 method),还是对客户端请求的响应(有 result/error)。 - bool isServerRequest; + // 一次解析即可分类:有 method → 服务器主动请求;有 result/error → 对客户端请求的响应。 + JsonRpcMessage? message; try { - using var doc = JsonDocument.Parse(data); - isServerRequest = doc.RootElement.TryGetProperty("method", out _); + message = await _manager.ReadMessageAsync(data); } catch { - isServerRequest = false; + _logger.Warn($"[McpClient][Http] Failed to parse SSE message."); + return; } - if (isServerRequest) + // 传入 messageInspector(如需检查初始化响应中的协议版本等字段) + if (message is not null) { - var request = TryParseServerRequest(data); - if (request is not null) - { - await _manager.HandleServerRequestAsync(request, token); - } + messageInspector?.Invoke(message); } - else + + switch (message) { - var response = await _manager.ReadResponseAsync(data); - if (response != null) - { + case JsonRpcRequest request: + await _manager.HandleServerRequestAsync(request, token); + break; + case JsonRpcResponse response: await _manager.HandleRespondAsync(response, token); - } + break; + default: + _logger.Warn($"[McpClient][Http] Unrecognized SSE message received."); + break; } } catch (Exception ex) @@ -433,15 +420,4 @@ private async Task DispatchSseEventAsync(string? eventName, string data, Cancell } } - private static JsonRpcRequest? TryParseServerRequest(string json) - { - try - { - return JsonSerializer.Deserialize(json, CompilerServices.McpInternalJsonContext.Default.JsonRpcRequest); - } - catch - { - return null; - } - } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs index a8cfbc1..5116f96 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IClientTransportManager.cs @@ -19,6 +19,23 @@ public interface IClientTransportManager /// RequestId MakeNewRequestId(); + /// + /// 提供给传输层调用。当传输层收到消息字符串行后,一次解析即可分类并反序列化为具体消息类型。 + /// + /// 消息字符串行。 + /// + /// 读取出来的 JSON-RPC 消息对象: + /// + /// 服务器主动发起的请求 → + /// 对客户端请求的响应 → + /// 无法识别或解析失败 → + /// + /// + /// + /// 如果读取失败,此方法会暴露底层的任何读取异常,传输层需处理好此异常(说明消息不正确)。 + /// + ValueTask ReadMessageAsync(string messageLine); + /// /// 提供给传输层调用。当传输层收到响应字符串行后,调用此方法可以将字符串读取为 JSON-RPC 响应对象。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs index 039d519..4c7f352 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs @@ -1,8 +1,6 @@ using System.Diagnostics; using System.Diagnostics.Contracts; using System.Text; -using System.Text.Json; -using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -108,52 +106,30 @@ private async Task RunLoopAsync(StdioProcessInfo stdio, CancellationToken cancel Log.Debug($"[McpClient][Stdio] ← {line}"); - // 检测是服务器主动发起的请求(有 method),还是对客户端请求的响应(有 result/error)。 - if (IsServerRequest(line)) + // 一次解析即可分类:有 method → 服务器主动请求;有 result/error → 对客户端请求的响应。 + JsonRpcMessage? message; + try { - var request = TryParseServerRequest(line); - if (request is null) - { - Log.Warn($"[McpClient][Stdio] Invalid server request received."); - continue; - } - await _manager.HandleServerRequestAsync(request, cancellationToken); - continue; + message = await _manager.ReadMessageAsync(line); } - - var response = await _manager.ParseAndCatchResponseAsync(line); - if (response is null) + catch { Log.Warn($"[McpClient][Stdio] Invalid server message received."); continue; } - await _manager.HandleRespondAsync(response, cancellationToken); - } - } - - private static bool IsServerRequest(string json) - { - try - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.TryGetProperty("method", out _); - } - catch - { - return false; - } - } - - private static JsonRpcRequest? TryParseServerRequest(string json) - { - try - { - return JsonSerializer.Deserialize(json, McpInternalJsonContext.Default.JsonRpcRequest); - } - catch - { - return null; + switch (message) + { + case JsonRpcRequest request: + await _manager.HandleServerRequestAsync(request, cancellationToken); + break; + case JsonRpcResponse response: + await _manager.HandleRespondAsync(response, cancellationToken); + break; + default: + Log.Warn($"[McpClient][Stdio] Unrecognized server message received."); + break; + } } } @@ -244,22 +220,3 @@ private readonly record struct StdioProcessInfo public required StreamReader StandardError { get; init; } } } - -file static class Extensions -{ - extension(IClientTransportManager manager) - { - public async ValueTask ParseAndCatchResponseAsync(string inputMessageText) - { - try - { - return await manager.ReadResponseAsync(inputMessageText); - } - catch - { - // 响应消息格式不正确,返回 null 后,原样给 MCP 客户端报告错误。 - return null; - } - } - } -} From a855395dc95ad755387b8e66905a45eaf8c34130 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:45:09 +0000 Subject: [PATCH 28/77] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20ServerTransportManag?= =?UTF-8?q?er=EF=BC=9APipeReader=20=E8=B5=84=E6=BA=90=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E5=92=8C=E5=A4=A7=E5=B0=8F=E5=86=99=E4=B8=8D=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E6=9F=A5=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/dotnet-campus/DotNetCampus.ModelContextProtocol/sessions/f1bdca63-ff02-48cb-97dc-c47e7d813b6b Co-authored-by: walterlv <9959623+walterlv@users.noreply.github.com> --- .../Transports/ServerTransportManager.cs | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index d680104..317087e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -150,8 +150,16 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio public async ValueTask ReadMessageAsync(ReadOnlyMemory messageMemory) { var pipeReader = PipeReader.Create(new ReadOnlySequence(messageMemory)); - using var doc = await JsonDocument.ParseAsync(pipeReader.AsStream()); - return ClassifyAndDeserialize(doc.RootElement); + try + { + using var stream = pipeReader.AsStream(); + using var doc = await JsonDocument.ParseAsync(stream); + return ClassifyAndDeserialize(doc.RootElement); + } + finally + { + await pipeReader.CompleteAsync(); + } } /// @@ -159,12 +167,12 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio /// private JsonRpcMessage? ClassifyAndDeserialize(JsonElement element) { - var hasMethod = element.TryGetProperty("method", out var methodElement); + var hasMethod = TryGetPropertyIgnoreCase(element, "method", out var methodElement); if (hasMethod) { // 有 id 且非 null → 请求;无 id 或 id 为 null → 通知。 - var hasId = element.TryGetProperty("id", out var idElement) + var hasId = TryGetPropertyIgnoreCase(element, "id", out var idElement) && idElement.ValueKind != JsonValueKind.Null; // initialize 请求即使 id 缺失或为 null 也应被视为请求(兼容旧客户端)。 @@ -185,7 +193,8 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio } } - var hasResultOrError = element.TryGetProperty("result", out _) || element.TryGetProperty("error", out _); + var hasResultOrError = TryGetPropertyIgnoreCase(element, "result", out _) + || TryGetPropertyIgnoreCase(element, "error", out _); if (hasResultOrError) { return element.Deserialize(McpInternalJsonContext.Default.JsonRpcResponse); @@ -194,6 +203,24 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio return null; } + /// + /// 以大小写不敏感的方式在 JSON 元素中查找属性,与 的 + /// PropertyNameCaseInsensitive = true 设置保持一致。 + /// + private static bool TryGetPropertyIgnoreCase(JsonElement element, string name, out JsonElement value) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + value = default; + return false; + } + public Task WriteMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken cancellationToken) => message switch { JsonRpcResponse response => JsonSerializer.SerializeAsync(stream, response, From 00bed631bf6233b46061a6c99b640f27aa60e3a7 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 9 Apr 2026 11:51:41 +0800 Subject: [PATCH 29/77] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=9B=B4=E7=AE=80?= =?UTF-8?q?=E5=8D=95=E7=9A=84=20JsonElement=EF=BC=8C=E4=B8=8D=E9=9C=80?= =?UTF-8?q?=E8=A6=81=20PipeReader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/ClientTransportManager.cs | 4 ++-- .../Transports/ServerTransportManager.cs | 21 +++++-------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index c7c864e..b3a126e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -48,8 +48,8 @@ public RequestId MakeNewRequestId() /// public ValueTask ReadMessageAsync(string messageLine) { - using var doc = JsonDocument.Parse(messageLine); - return ValueTask.FromResult(ClassifyAndDeserialize(doc.RootElement)); + var message = JsonElement.Parse(messageLine); + return ValueTask.FromResult(ClassifyAndDeserialize(message)); } /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index 317087e..eed6ce0 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -1,7 +1,5 @@ -using System.Buffers; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; -using System.IO.Pipelines; using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; using DotNetCampus.ModelContextProtocol.Hosting.Services; @@ -137,8 +135,8 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio public ValueTask ReadMessageAsync(string messageLine) { - using var doc = JsonDocument.Parse(messageLine); - return ValueTask.FromResult(ClassifyAndDeserialize(doc.RootElement)); + var message = JsonElement.Parse(messageLine); + return ValueTask.FromResult(ClassifyAndDeserialize(message)); } public async ValueTask ReadMessageAsync(Stream messageStream) @@ -147,19 +145,10 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio return ClassifyAndDeserialize(doc.RootElement); } - public async ValueTask ReadMessageAsync(ReadOnlyMemory messageMemory) + public ValueTask ReadMessageAsync(ReadOnlyMemory messageMemory) { - var pipeReader = PipeReader.Create(new ReadOnlySequence(messageMemory)); - try - { - using var stream = pipeReader.AsStream(); - using var doc = await JsonDocument.ParseAsync(stream); - return ClassifyAndDeserialize(doc.RootElement); - } - finally - { - await pipeReader.CompleteAsync(); - } + var message = JsonElement.Parse(messageMemory.Span); + return ValueTask.FromResult(ClassifyAndDeserialize(message)); } /// From 71549d1a1432b523d0f7a959d8970953e241e6aa Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 9 Apr 2026 11:56:20 +0800 Subject: [PATCH 30/77] =?UTF-8?q?=E6=88=91=E8=A7=89=E5=BE=97=E6=B2=A1?= =?UTF-8?q?=E5=BF=85=E8=A6=81=E4=B8=BA=E4=BA=86=E8=BF=99=E4=B8=AA=E4=B8=8D?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E7=9A=84=E5=86=99=E6=B3=95=E6=B5=AA=E8=B4=B9?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/ServerTransportManager.cs | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index eed6ce0..2eb6995 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -156,13 +156,12 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio /// private JsonRpcMessage? ClassifyAndDeserialize(JsonElement element) { - var hasMethod = TryGetPropertyIgnoreCase(element, "method", out var methodElement); + var hasMethod = element.TryGetProperty("method", out var methodElement); if (hasMethod) { // 有 id 且非 null → 请求;无 id 或 id 为 null → 通知。 - var hasId = TryGetPropertyIgnoreCase(element, "id", out var idElement) - && idElement.ValueKind != JsonValueKind.Null; + var hasId = element.TryGetProperty("id", out var idElement) && idElement.ValueKind != JsonValueKind.Null; // initialize 请求即使 id 缺失或为 null 也应被视为请求(兼容旧客户端)。 var isInitialize = methodElement.GetString() == RequestMethods.Initialize; @@ -182,8 +181,7 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio } } - var hasResultOrError = TryGetPropertyIgnoreCase(element, "result", out _) - || TryGetPropertyIgnoreCase(element, "error", out _); + var hasResultOrError = element.TryGetProperty("result", out _) || element.TryGetProperty("error", out _); if (hasResultOrError) { return element.Deserialize(McpInternalJsonContext.Default.JsonRpcResponse); @@ -192,24 +190,6 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio return null; } - /// - /// 以大小写不敏感的方式在 JSON 元素中查找属性,与 的 - /// PropertyNameCaseInsensitive = true 设置保持一致。 - /// - private static bool TryGetPropertyIgnoreCase(JsonElement element, string name, out JsonElement value) - { - foreach (var property in element.EnumerateObject()) - { - if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) - { - value = property.Value; - return true; - } - } - value = default; - return false; - } - public Task WriteMessageAsync(Stream stream, JsonRpcMessage message, CancellationToken cancellationToken) => message switch { JsonRpcResponse response => JsonSerializer.SerializeAsync(stream, response, From 52f6e76c2b4b1591080406a413b7ffe789787225 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 9 Apr 2026 13:02:58 +0800 Subject: [PATCH 31/77] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/ServerTransportManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index 2eb6995..26ca0a6 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -164,7 +164,7 @@ public bool TryGetSession(string sessionId, [NotNullWhen(true)] out T? sessio var hasId = element.TryGetProperty("id", out var idElement) && idElement.ValueKind != JsonValueKind.Null; // initialize 请求即使 id 缺失或为 null 也应被视为请求(兼容旧客户端)。 - var isInitialize = methodElement.GetString() == RequestMethods.Initialize; + var isInitialize = methodElement.ValueKind is JsonValueKind.String && methodElement.GetString() == RequestMethods.Initialize; if (hasId || isInitialize) { From 071044395fcf6201442dc3930adeab7c3d05d3ea Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 9 Apr 2026 17:10:15 +0800 Subject: [PATCH 32/77] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=BE=85=E5=8A=A9?= =?UTF-8?q?=E4=BC=A0=E8=BE=93=E5=B1=82=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=9A=84=E7=B1=BB=EF=BC=88=E9=81=BF=E5=85=8D=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=89=93=E5=A4=AA=E5=A4=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Clients/McpClientBuilder.cs | 19 +++- .../Servers/McpServerBuilder.cs | 19 +++- .../Transports/ClientTransportManager.cs | 8 +- .../McpTransportLoggerExtensions.cs | 103 ++++++++++++++++++ .../Transports/ServerTransportManager.cs | 9 +- 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs index e486d17..dec222f 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs @@ -15,6 +15,7 @@ public class McpClientBuilder private string _clientName = ""; private string _clientVersion = "0.0.0"; private IMcpLogger? _logger; + private McpTransportRawMessageLoggingDetailLevel _rawMessageLoggingDetailLevel = McpTransportRawMessageLoggingDetailLevel.None; private IServiceProvider? _serviceProvider; private Func? _transportFactory; private ClientCapabilities _capabilities = new(); @@ -44,6 +45,19 @@ public McpClientBuilder WithLogger(IMcpLogger logger) return this; } + /// + /// 配置 MCP 客户端的日志记录器。 + /// + /// 日志记录器。 + /// 传输层原始消息的日志记录详细级别。 + /// 用于链式调用的 MCP 客户端生成器。 + public McpClientBuilder WithLogger(IMcpLogger logger, McpTransportRawMessageLoggingDetailLevel rawMessageLoggingDetailLevel) + { + _logger = logger; + _rawMessageLoggingDetailLevel = rawMessageLoggingDetailLevel; + return this; + } + /// /// 配置 MCP 客户端的服务提供器。 /// @@ -186,7 +200,10 @@ public McpClient Build() ServiceProvider = _serviceProvider, }; - var transportManager = new ClientTransportManager(context); + var transportManager = new ClientTransportManager(context) + { + RawMessageLoggingDetailLevel = _rawMessageLoggingDetailLevel, + }; context.Transport = transportManager; if (_samplingHandler is { } handler) diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs index 8ec5cb6..02f1c18 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs @@ -21,6 +21,7 @@ public class McpServerBuilder(string serverName, string serverVersion) private readonly McpServerToolsProvider _tools = new(); private readonly McpServerResourcesProvider _resources = new(); private IMcpLogger? _logger; + private McpTransportRawMessageLoggingDetailLevel _rawMessageLoggingDetailLevel = McpTransportRawMessageLoggingDetailLevel.None; private IMcpServerToolJsonSerializer? _jsonSerializer; private string? _jsonSerializerTypeName; private IServiceProvider? _serviceProvider; @@ -96,6 +97,19 @@ public McpServerBuilder WithLogger(IMcpLogger logger) return this; } + /// + /// 配置 MCP 服务器的日志记录器。 + /// + /// 日志记录器。 + /// 传输层原始消息的日志记录详细级别。 + /// 用于链式调用的 MCP 服务器生成器。 + public McpServerBuilder WithLogger(IMcpLogger logger, McpTransportRawMessageLoggingDetailLevel rawMessageLoggingDetailLevel) + { + _logger = logger; + _rawMessageLoggingDetailLevel = rawMessageLoggingDetailLevel; + return this; + } + /// /// 配置自定义的 JSON 序列化上下文。 /// @@ -187,7 +201,10 @@ public McpServer Build() context.Handlers = _requestHandlers is { } requestHandlers ? requestHandlers(server) : new McpServerRequestHandlers(server); - var transportManager = new ServerTransportManager(server, context); + var transportManager = new ServerTransportManager(server, context) + { + RawMessageLoggingDetailLevel = _rawMessageLoggingDetailLevel, + }; context.Transport = transportManager; foreach (var factory in _transportFactories) { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index b3a126e..7eaea4a 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -14,7 +14,7 @@ namespace DotNetCampus.ModelContextProtocol.Transports; /// /// 用于管理 MCP 客户端传输层的管理器。 /// -internal class ClientTransportManager(IClientTransportContext context) : IClientTransportManager +internal class ClientTransportManager(IClientTransportContext context) : IClientTransportManager, IMcpTransportLogger { private readonly ConcurrentDictionary> _pendingRequests = []; private IClientTransport? _transport; @@ -23,6 +23,12 @@ internal class ClientTransportManager(IClientTransportContext context) : IClient /// public IClientTransportContext Context { get; } = context; + /// + public IMcpLogger Logger => Context.Logger; + + /// + public McpTransportRawMessageLoggingDetailLevel RawMessageLoggingDetailLevel { get; init; } + /// /// 设置传输层实例。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs new file mode 100644 index 0000000..9ec61ab --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; + +namespace DotNetCampus.ModelContextProtocol.Transports; + +/// +/// 专为传输层原始消息进行日志记录的扩展方法。 +/// +internal static class McpTransportLoggerExtensions +{ + private const int TrimmedRawMessageMaxLength = 80; + + /// MCP 传输层管理器。 + extension(IServerTransportManager manager) + { + public void LogRawIn(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); + public void LogRawIn(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); + public void LogRawOut(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); + public void LogRawOut(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); + } + + /// MCP 传输层管理器。 + extension(IClientTransportManager manager) + { + public void LogRawIn(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); + public void LogRawIn(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); + public void LogRawOut(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); + public void LogRawOut(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); + } + + private static void LogRaw(this IMcpTransportLogger transportLogger, string tag, string direction, string jsonRpcRawMessage) + { + if (transportLogger.RawMessageLoggingDetailLevel is not McpTransportRawMessageLoggingDetailLevel.None + && transportLogger.Logger.IsEnabled(LoggingLevel.Debug)) + { + var trimmedMessage = transportLogger.RawMessageLoggingDetailLevel is McpTransportRawMessageLoggingDetailLevel.Full + || jsonRpcRawMessage.Length <= TrimmedRawMessageMaxLength + ? jsonRpcRawMessage + : jsonRpcRawMessage[..TrimmedRawMessageMaxLength] + "...(trimmed)"; + transportLogger.Logger.Debug($"[McpServer]{tag} {direction} {trimmedMessage}"); + } + } + + private static void LogRaw(this IMcpTransportLogger transportLogger, string tag, string direction, JsonRpcMessage jsonRpcRawMessage) + { + if (transportLogger.RawMessageLoggingDetailLevel is not McpTransportRawMessageLoggingDetailLevel.None + && transportLogger.Logger.IsEnabled(LoggingLevel.Debug)) + { + var json = JsonSerializer.Serialize(jsonRpcRawMessage, jsonRpcRawMessage switch + { + JsonRpcNotification => McpInternalJsonContext.Default.JsonRpcNotification, + JsonRpcRequest => McpInternalJsonContext.Default.JsonRpcRequest, + JsonRpcResponse => McpInternalJsonContext.Default.JsonRpcResponse, + _ => throw new InvalidOperationException($"Unexpected JsonRpcMessage type: {jsonRpcRawMessage.GetType().FullName}"), + }); + var trimmedMessage = transportLogger.RawMessageLoggingDetailLevel is McpTransportRawMessageLoggingDetailLevel.Full + || json.Length <= TrimmedRawMessageMaxLength + ? json + : json[..TrimmedRawMessageMaxLength] + "...(trimmed)"; + transportLogger.Logger.Debug($"[McpServer]{tag} {direction} {trimmedMessage}"); + } + } +} + +/// +/// 提供 MCP 传输层日志记录功能的接口。实现此接口的类可以为传输层原始消息提供日志记录支持。 +/// +internal interface IMcpTransportLogger +{ + /// + /// 获取用于记录 MCP 传输层日志的 实例。 + /// + IMcpLogger Logger { get; } + + /// + /// 获取 MCP 传输层原始消息日志记录的详细程度。根据此属性的值,传输层可以决定是否记录原始消息日志,以及记录多少细节。 + /// + McpTransportRawMessageLoggingDetailLevel RawMessageLoggingDetailLevel { get; } +} + +/// +/// MCP 传输层原始消息日志记录的详细程度。 +/// +public enum McpTransportRawMessageLoggingDetailLevel +{ + /// + /// 不记录原始消息日志。 + /// + None, + + /// + /// 记录裁剪的原始消息。过长的消息会被裁剪以避免日志过大。 + /// + Trimmed, + + /// + /// 记录完整的原始消息。可能会导致日志过大,一般仅建议在调试时使用。 + /// + Full, +} diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs index 26ca0a6..289713d 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportManager.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -10,7 +11,7 @@ namespace DotNetCampus.ModelContextProtocol.Transports; -internal class ServerTransportManager(McpServer server, McpServerContext context) : IServerTransportManager +internal class ServerTransportManager(McpServer server, McpServerContext context) : IServerTransportManager, IMcpTransportLogger { /// /// 表示 MCP 服务正在运行的 。 @@ -50,6 +51,12 @@ internal class ServerTransportManager(McpServer server, McpServerContext context /// public IServerTransportContext Context => context; + /// + public IMcpLogger Logger => Context.Logger; + + /// + public McpTransportRawMessageLoggingDetailLevel RawMessageLoggingDetailLevel { get; init; } + /// /// 获取已注册的传输层列表。 /// From 9b5cd1066a0593b2510fa107461f3b7d4b25df48 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 9 Apr 2026 17:43:03 +0800 Subject: [PATCH 33/77] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=8F=97=E7=AE=A1=E7=90=86=E7=9A=84=E4=BC=A0=E8=BE=93=E5=B1=82?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E6=97=A5=E5=BF=97=E5=A4=A7=E5=B9=85=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E6=8E=A7=E5=88=B6=E5=8F=B0=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/Http/HttpClientTransport.cs | 4 ++-- .../Http/HttpServerTransportSession.cs | 9 ++------- .../Http/LocalHostHttpServerTransport.cs | 18 ++++++++++-------- .../Transports/Stdio/StdioClientTransport.cs | 4 ++-- .../Transports/Stdio/StdioServerTransport.cs | 4 ++-- .../Stdio/StdioServerTransportSession.cs | 10 ++++++---- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index 0fb8eb2..47ef307 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -151,7 +151,7 @@ private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, Cancellatio content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); request.Content = content; - _logger.Debug($"[McpClient][Http] → {jsonContent}"); + _manager.LogRawOut("[Http]", jsonContent); // 4. 发送请求 (ResponseHeadersRead 以支持流式响应) var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); @@ -378,7 +378,7 @@ private async Task DispatchSseEventAsync(string? eventName, string data, Cancell if (string.IsNullOrEmpty(eventName) || string.Equals(eventName, "message", StringComparison.OrdinalIgnoreCase)) { - _logger.Debug($"[McpClient][Http] ← {data}"); + _manager.LogRawIn("[Http]", data); try { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs index 3156ea1..47d7c07 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs @@ -1,4 +1,3 @@ -using System.Text; using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -101,12 +100,8 @@ public async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, Ca // Serialize if (Log.IsEnabled(LoggingLevel.Debug)) { - using var ms = new MemoryStream(); - await _manager.WriteMessageAsync(ms, message, ct); - var bytes = ms.ToArray(); - var json = Encoding.UTF8.GetString(bytes); - Log.Debug($"{_logPrefix} → {json}"); - await stream.WriteAsync(bytes, ct); + await _manager.WriteMessageAsync(stream, message, ct); + _manager.LogRawOut(_logPrefix, message); } else { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 3ed1869..f44bf3c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -201,11 +201,9 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat return; } - if (Log.IsEnabled(LoggingLevel.Debug) && message is not null) + if (message is not null) { - using var ms = new MemoryStream(); - await _manager.WriteMessageAsync(ms, message, cancellationToken); - Log.Debug($"[McpServer][StreamableHttp] ← {Encoding.UTF8.GetString(ms.ToArray())}"); + _manager.LogRawIn("[StreamableHttp]", message); } var sessionIdStr = request.Headers[SessionIdHeader]; @@ -251,7 +249,8 @@ private async Task HandleClientResponseAsync(HttpListenerContext context, string /// /// /// - private async Task HandleNotificationAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcNotification notification, CancellationToken cancellationToken) + private async Task HandleNotificationAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcNotification notification, + CancellationToken cancellationToken) { if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) { @@ -272,7 +271,8 @@ await _manager.HandleRequestAsync( /// /// /// - private async Task HandleRpcRequestAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) + private async Task HandleRpcRequestAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest, + CancellationToken cancellationToken) { var session = await GetOrCreateSessionAsync(context, sessionIdStr, jsonRpcRequest); if (session is null) return; @@ -330,7 +330,8 @@ private async Task HandleRpcRequestAsync(HttpListenerContext context, string? se /// /// /// - private async Task HandleInitializeAsync(HttpListenerContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) + private async Task HandleInitializeAsync(HttpListenerContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, + CancellationToken cancellationToken) { var initResponse = await _manager.HandleRequestAsync(jsonRpcRequest, s => s.AddTransportSession(session, Log), @@ -365,7 +366,8 @@ private async Task HandleInitializeAsync(HttpListenerContext context, HttpServer /// /// /// - private async Task HandleSseRequestAsync(HttpListenerContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) + private async Task HandleSseRequestAsync(HttpListenerContext context, HttpServerTransportSession session, JsonRpcRequest jsonRpcRequest, + CancellationToken cancellationToken) { context.Response.StatusCode = (int)HttpStatusCode.OK; context.Response.ContentType = "text/event-stream"; diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs index 4c7f352..19127f3 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs @@ -68,7 +68,7 @@ public async ValueTask SendMessageAsync(JsonRpcMessage message, CancellationToke } var line = _manager.WriteMessageAsync(message); - Log.Debug($"[McpClient][Stdio] → {line}"); + _manager.LogRawOut("[Stdio]", line); await stdio.StandardInput.WriteAsync(line); await stdio.StandardInput.WriteAsync('\n'); await stdio.StandardInput.FlushAsync(); @@ -104,7 +104,7 @@ private async Task RunLoopAsync(StdioProcessInfo stdio, CancellationToken cancel continue; } - Log.Debug($"[McpClient][Stdio] ← {line}"); + _manager.LogRawIn("[Stdio]", line); // 一次解析即可分类:有 method → 服务器主动请求;有 result/error → 对客户端请求的响应。 JsonRpcMessage? message; diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs index 1425de8..72b98d4 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs @@ -29,7 +29,7 @@ public class StdioServerTransport : IServerTransport public StdioServerTransport(IServerTransportManager manager) { _manager = manager; - _session = new StdioServerTransportSession(manager.Context.Logger); + _session = new StdioServerTransportSession(manager); } private IMcpLogger Log => _manager.Context.Logger; @@ -92,7 +92,7 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) continue; } - Log.Debug($"[McpServer][Stdio] ← {line}"); + _manager.LogRawIn("Stdio", line); JsonRpcMessage? message; try diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs index 856f56c..e6e7383 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransportSession.cs @@ -14,16 +14,18 @@ public class StdioServerTransportSession : ServerTransportSession { private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly IServerTransportManager _manager; private readonly IMcpLogger _logger; private StreamWriter? _output; /// /// 初始化 类的新实例。 /// - /// 日志记录器。 - public StdioServerTransportSession(IMcpLogger logger) + /// 辅助管理 MCP 传输层的管理器。 + public StdioServerTransportSession(IServerTransportManager manager) { - _logger = logger; + _manager = manager; + _logger = manager.Context.Logger; } /// @@ -62,7 +64,7 @@ private async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ca await JsonSerializer.SerializeAsync(ms, message, GetTypeInfo(message), cancellationToken).ConfigureAwait(false); var bytes = ms.ToArray(); var json = Encoding.UTF8.GetString(bytes); - _logger.Debug($"[McpServer][Stdio] → {json}"); + _manager.LogRawOut("[Stdio]", json); await output.BaseStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); } else From bbb20b0c3cb45c41a7b366b048f343658c3aad7d Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 9 Apr 2026 17:54:09 +0800 Subject: [PATCH 34/77] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Http/HttpServerTransportSession.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs index 47d7c07..905f16e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs @@ -20,10 +20,11 @@ public class HttpServerTransportSession : ServerTransportSession private readonly SemaphoreSlim _writeLock = new(1, 1); /// - /// 当前 POST 请求绑定的 SSE 输出流。 + /// 当前 POST 请求绑定的 SSE 输出流,使用 AsyncLocal 确保每个异步执行上下文(即每个并发 POST 请求) + /// 都拥有独立的值,避免多个并发请求相互覆盖导致竞态条件。 /// 非 null 时,SendRequestAsync 直接向此流写入采样请求。 /// - private Stream? _currentRequestSseStream; + private static readonly AsyncLocal _currentRequestSseStream = new(); private IMcpLogger Log => _manager.Context.Logger; @@ -49,21 +50,23 @@ public HttpServerTransportSession(IServerTransportManager manager, string sessio /// public IDisposable SetRequestSseStream(Stream stream) { - _currentRequestSseStream = stream; + _currentRequestSseStream.Value = stream; return new SseStreamScope(this, stream); } private void ClearRequestSseStream(Stream stream) { - // 仅在字段仍指向本次绑定的 stream 时才清除,避免并发请求相互覆盖。 - Interlocked.CompareExchange(ref _currentRequestSseStream, null, stream); + if (_currentRequestSseStream.Value == stream) + { + _currentRequestSseStream.Value = null; + } } /// protected override async Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - var stream = _currentRequestSseStream - ?? throw new InvalidOperationException("当前没有绑定的 SSE 流,无法发送服务端主动请求。"); + var stream = _currentRequestSseStream.Value + ?? throw new InvalidOperationException("当前没有绑定的 SSE 流,无法发送服务端主动请求。"); Log.Debug($"{_logPrefix} Sending server-initiated request. Method={request.Method}, Id={request.Id}, SessionId={SessionId}"); From 3ba6c0572230dae42373341be9371502ad9cf7d5 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 9 Apr 2026 19:47:24 +0800 Subject: [PATCH 35/77] =?UTF-8?q?=E4=B8=BA=20GET=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TouchSocket/TouchSocketHttpServerTransport.cs | 4 ++++ .../Transports/Http/LocalHostHttpServerTransport.cs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 339bbdf..4870b70 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -233,6 +233,8 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, context.Response.ContentType = "text/event-stream"; context.Response.Headers.Add("Cache-Control", "no-cache"); + Log.Info($"[McpServer][TouchSocket] SSE connection established. SessionId={sessionId}"); + try { context.Response.IsChunk = true; @@ -248,10 +250,12 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, await output.WriteAsync(SseKeepAliveBytes, cancellationToken); await output.FlushAsync(cancellationToken); } + Log.Info($"[McpServer][TouchSocket] SSE connection cancelled. SessionId={sessionId}"); } catch (OperationCanceledException) { // 正常关闭 + Log.Info($"[McpServer][TouchSocket] SSE connection ended. SessionId={sessionId}"); } catch (Exception ex) { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index f44bf3c..5ed09db 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -420,6 +420,8 @@ private async Task HandleGetRequestAsync(HttpListenerContext context, Cancellati context.Response.ContentType = "text/event-stream"; context.Response.Headers["Cache-Control"] = "no-cache"; + Log.Info($"[McpServer][StreamableHttp] SSE connection established. SessionId={sessionId}"); + try { var output = context.Response.OutputStream; @@ -434,14 +436,16 @@ private async Task HandleGetRequestAsync(HttpListenerContext context, Cancellati await output.WriteAsync(SseKeepAliveBytes, cancellationToken); await output.FlushAsync(cancellationToken); } + Log.Info($"[McpServer][StreamableHttp] SSE connection cancelled. SessionId={sessionId}"); } catch (OperationCanceledException) { // 正常关闭 + Log.Info($"[McpServer][StreamableHttp] SSE connection ended. SessionId={sessionId}"); } catch (Exception ex) { - Log.Debug($"[McpServer][StreamableHttp] SSE connection ended. SessionId={sessionId}, Error={ex.Message}"); + Log.Info($"[McpServer][StreamableHttp] SSE connection ended. SessionId={sessionId}, Error={ex.Message}"); } finally { From 6032afa027567a6cd3fdfc0234116f5ae67d0d26 Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 10 Apr 2026 14:32:22 +0800 Subject: [PATCH 36/77] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=9B=B4=E8=AF=A6?= =?UTF-8?q?=E7=BB=86=E7=9A=84=E5=8E=9F=E5=A7=8B=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/knowledge/logging-style-guide.md | 72 +++++++++++++++++++ .../Transports/Ipc/IpcServerTransport.cs | 6 ++ .../Ipc/IpcServerTransportSession.cs | 1 + .../TouchSocketHttpServerTransport.cs | 6 ++ .../Transports/Http/HttpClientTransport.cs | 15 ++-- .../Http/HttpServerTransportSession.cs | 11 +-- .../Http/LocalHostHttpServerTransport.cs | 9 ++- .../McpTransportLoggerExtensions.cs | 52 ++++++++++---- .../Transports/Stdio/StdioServerTransport.cs | 3 +- 9 files changed, 140 insertions(+), 35 deletions(-) diff --git a/docs/knowledge/logging-style-guide.md b/docs/knowledge/logging-style-guide.md index ae27612..cbf5e5f 100644 --- a/docs/knowledge/logging-style-guide.md +++ b/docs/knowledge/logging-style-guide.md @@ -366,6 +366,78 @@ Log.Warn($"[McpClient][Http] SSE connection error, reconnecting. Error={ex.Messa Log.Error($"[McpServer][StreamableHttp] Failed to start listener: {ex}"); ``` +## 原始消息日志(Raw Message Log) + +原始消息日志专门用于记录 MCP 传输层收发的完整 JSON-RPC 消息内容,是与官方参考实现对比验证的核心手段。 + +### 使用方式 + +不要直接调用 `IMcpLogger`,而是通过传输层管理器上的扩展方法: + +```csharp +// 服务端接收(传输层 _manager 为 IServerTransportManager) +_manager.LogRawIn("[StreamableHttp]", channel, message); + +// 服务端发送 +_manager.LogRawOut("[StreamableHttp]", channel, message); + +// 客户端接收(传输层 _manager 为 IClientTransportManager) +_manager.LogRawIn("[Http]", channel, data); + +// 客户端发送 +_manager.LogRawOut("[Http]", channel, jsonContent); +``` + +- 由 `McpTransportRawMessageLoggingDetailLevel` 控制是否记录及记录多少(`None` / `Trimmed` / `Full`) +- 日志级别固定为 `Debug` +- 只有在 `Debug` 级别启用时才实际输出,方法内部自行判断,调用方无需包裹 `if (IsEnabled(Debug))` + +### 输出格式 + +``` +{role}{tag} {direction} [via {channel}] {rawMessage} +``` + +示例: + +``` +[McpServer][StreamableHttp] ← [via POST, SessionId=abc123] {"jsonrpc":"2.0","method":"initialize",...} +[McpServer][StreamableHttp] → [via POST/json, SessionId=abc123] {"jsonrpc":"2.0","id":"1","result":{...}} +[McpServer][StreamableHttp] → [via POST/sse, SessionId=abc123] {"jsonrpc":"2.0","method":"sampling/createMessage",...} +[McpClient][Http] → [via POST] {"jsonrpc":"2.0","method":"initialize",...} +[McpClient][Http] ← [via POST/json, SessionId=init] {"jsonrpc":"2.0","id":"1","result":{...}} +[McpClient][Http] ← [via GET/sse, SessionId=abc123] {"jsonrpc":"2.0","id":"2","result":{...}} +[McpServer][Stdio] → {"jsonrpc":"2.0","id":"1","result":{...}} +[McpClient][Stdio] → {"jsonrpc":"2.0","method":"initialize",...} +[McpServer][Ipc] ← {"jsonrpc":"2.0","method":"tools/call",...} +``` + +### 渠道标识(channel)规范 + +`channel` 参数描述消息经由的具体 HTTP/传输渠道,格式为 `"类型[, SessionId=xxx]"`。 + +| 场景 | channel 值 | +|------|------------| +| 服务端接收 POST 请求体(初始化前,无会话) | `"POST"` | +| 服务端接收 POST 请求体(有会话) | `$"POST, SessionId={sessionId}"` | +| 服务端发送 POST application/json 响应 | `$"POST/json, SessionId={session.SessionId}"` | +| 服务端发送 POST 内嵌瞬态 SSE | `$"POST/sse, SessionId={SessionId}"` | +| 客户端发送 POST 请求(初始化前) | `"POST"` | +| 客户端发送 POST 请求(有会话) | `$"POST, SessionId={_sessionId}"` | +| 客户端接收 POST application/json 响应 | `$"POST/json, SessionId={_sessionId ?? "init"}"` | +| 客户端接收 POST 内嵌瞬态 SSE | `$"POST/sse, SessionId={_sessionId ?? "init"}"` | +| 客户端接收 GET SSE 后台循环 | `$"GET/sse, SessionId={_sessionId}"` | +| STDIO / IPC | 无需 channel(渠道唯一,省略参数即可) | + +### 覆盖要求 + +每条 JSON-RPC 消息必须在以下两个时刻之一被记录,且只记录一次: + +- **接收侧**:解析完成后(有 `JsonRpcMessage` 对象)、分发给上层逻辑之前 +- **发送侧**:序列化写入流/通道之前 + +--- + ## 代码实现 ### 日志属性命名 diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs index 803a5f5..31d0e72 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransport.cs @@ -136,6 +136,11 @@ private async Task HandleMessageAsync(PeerProxy peer, IpcMessage message) parsed = null; } + if (parsed is not null) + { + _manager.LogRawIn("[Ipc]", parsed); + } + switch (parsed) { case JsonRpcResponse response: @@ -188,6 +193,7 @@ public async ValueTask RespondJsonRpcAsync(PeerProxy peer, JsonRpcResponse respo { try { + manager.LogRawOut("[Ipc]", response); using var ms = new MemoryStream(); await manager.WriteMessageAsync(ms, response, cancellationToken); await peer.NotifyAsync(new IpcMessage("", new IpcMessageBody(ms.GetBuffer(), 0, (int)ms.Length), IpcServerTransportSession.McpIpcHeader)); diff --git a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs index f484089..c16b9e4 100644 --- a/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol.Ipc/Transports/Ipc/IpcServerTransportSession.cs @@ -48,6 +48,7 @@ protected override async Task SendRequestMessageAsync(JsonRpcRequest request, Ca throw new InvalidOperationException("IPC 对端代理尚未设置,无法发送服务端主动请求。请确认 SetPeer 已在连接建立时被调用。"); } + _manager.LogRawOut("[Ipc]", request); using var ms = new MemoryStream(); await _manager.WriteMessageAsync(ms, request, cancellationToken); await peer.NotifyAsync(new IpcMessage("McpServer.SendMessage", new IpcMessageBody(ms.GetBuffer(), 0, (int)ms.Length), McpIpcHeader)); diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 4870b70..8d99d14 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -299,6 +299,11 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca return; } + if (message is not null) + { + _manager.LogRawIn("[TouchSocket]", $"POST, SessionId={sessionIdStr}", message); + } + switch (message) { case JsonRpcResponse jsonRpcResponse: @@ -448,6 +453,7 @@ private async ValueTask HandleInitializeAsync(HttpContext context, HttpServerTra if (initResponse != null) { Log.Debug($"[McpServer][TouchSocket] Sending initialize response. SessionId={session.SessionId}, MessageId={jsonRpcRequest.Id}"); + _manager.LogRawOut("[TouchSocket]", $"POST/json, SessionId={session.SessionId}", initResponse); await context.RespondJsonRpcAsync(_manager, HttpStatusCode.OK, initResponse, cancellationToken); } else diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index 47ef307..4a29d94 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -151,7 +151,7 @@ private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, Cancellatio content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); request.Content = content; - _manager.LogRawOut("[Http]", jsonContent); + _manager.LogRawOut("[Http]", $"POST, SessionId={_sessionId}", jsonContent); // 4. 发送请求 (ResponseHeadersRead 以支持流式响应) var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); @@ -181,7 +181,7 @@ private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, Cancellatio _logger.Debug($"[McpClient][Http] Received SSE stream response."); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - await ProcessSseStreamAsync(stream, cancellationToken, isInitialize + await ProcessSseStreamAsync(stream, cancellationToken, $"POST/sse, SessionId={_sessionId ?? "init"}", isInitialize ? (msg) => { if (msg is JsonRpcResponse { Result: { ValueKind: JsonValueKind.Object } resultElement }) @@ -212,6 +212,7 @@ await ProcessSseStreamAsync(stream, cancellationToken, isInitialize _protocolVersion = TryExtractProtocolVersion(resultElement, "POST"); } + _manager.LogRawIn("[Http]", $"POST/json, SessionId={_sessionId ?? "init"}", rpcResponse); await _manager.HandleRespondAsync(rpcResponse, cancellationToken); } } @@ -304,7 +305,7 @@ private async Task ReceiveLoopAsync(CancellationToken token) } await using var stream = await response.Content.ReadAsStreamAsync(token); - await ProcessSseStreamAsync(stream, token); + await ProcessSseStreamAsync(stream, token, $"GET/sse, SessionId={_sessionId}"); } _logger.Info($"[McpClient][Http] SSE stream ended, reconnecting."); } @@ -331,7 +332,7 @@ private async Task ReceiveLoopAsync(CancellationToken token) // --- SSE 解析核心逻辑 --- - private async Task ProcessSseStreamAsync(Stream stream, CancellationToken token, Action? messageInspector = null) + private async Task ProcessSseStreamAsync(Stream stream, CancellationToken token, string channel, Action? messageInspector = null) { using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); @@ -351,7 +352,7 @@ private async Task ProcessSseStreamAsync(Stream stream, CancellationToken token, { if (dataBuffer.Length > 0) { - await DispatchSseEventAsync(currentEvent, dataBuffer.ToString(), token, messageInspector); + await DispatchSseEventAsync(currentEvent, dataBuffer.ToString(), token, channel, messageInspector); dataBuffer.Clear(); currentEvent = null; } @@ -372,13 +373,13 @@ private async Task ProcessSseStreamAsync(Stream stream, CancellationToken token, } } - private async Task DispatchSseEventAsync(string? eventName, string data, CancellationToken token, Action? messageInspector) + private async Task DispatchSseEventAsync(string? eventName, string data, CancellationToken token, string channel, Action? messageInspector) { if (string.IsNullOrEmpty(data) || data == "[DONE]") return; if (string.IsNullOrEmpty(eventName) || string.Equals(eventName, "message", StringComparison.OrdinalIgnoreCase)) { - _manager.LogRawIn("[Http]", data); + _manager.LogRawIn("[Http]", channel, data); try { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs index 905f16e..239b48c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpServerTransportSession.cs @@ -101,15 +101,8 @@ public async Task WriteSseMessageAsync(Stream stream, JsonRpcMessage message, Ca await stream.WriteAsync(DataPrefixBytes, ct); // Serialize - if (Log.IsEnabled(LoggingLevel.Debug)) - { - await _manager.WriteMessageAsync(stream, message, ct); - _manager.LogRawOut(_logPrefix, message); - } - else - { - await _manager.WriteMessageAsync(stream, message, ct); - } + await _manager.WriteMessageAsync(stream, message, ct); + _manager.LogRawOut(_logPrefix, $"POST/sse, SessionId={SessionId}", message); // \n\n (End of event) await stream.WriteAsync(NewLineBytes, ct); diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 5ed09db..8df41f6 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -5,7 +5,6 @@ using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; -using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Http; @@ -201,13 +200,12 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat return; } + var sessionIdStr = request.Headers[SessionIdHeader]; + if (message is not null) { - _manager.LogRawIn("[StreamableHttp]", message); + _manager.LogRawIn("[StreamableHttp]", $"POST, SessionId={sessionIdStr}", message); } - - var sessionIdStr = request.Headers[SessionIdHeader]; - switch (message) { case JsonRpcResponse jsonRpcResponse: @@ -343,6 +341,7 @@ private async Task HandleInitializeAsync(HttpListenerContext context, HttpServer context.Response.StatusCode = (int)HttpStatusCode.OK; try { + _manager.LogRawOut("[StreamableHttp]", $"POST/json, SessionId={session.SessionId}", initResponse); await _manager.WriteMessageAsync(context.Response.OutputStream, initResponse, cancellationToken); context.Response.SafeClose(); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs index 9ec61ab..38eea92 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/McpTransportLoggerExtensions.cs @@ -9,29 +9,53 @@ namespace DotNetCampus.ModelContextProtocol.Transports; /// /// 专为传输层原始消息进行日志记录的扩展方法。 /// -internal static class McpTransportLoggerExtensions +public static class McpTransportLoggerExtensions { private const int TrimmedRawMessageMaxLength = 80; /// MCP 传输层管理器。 extension(IServerTransportManager manager) { - public void LogRawIn(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); - public void LogRawIn(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); - public void LogRawOut(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); - public void LogRawOut(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "←", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "←", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, string channel, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "←", jsonRpcRawMessage, channel); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, string channel, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "←", jsonRpcRawMessage, channel); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "→", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "→", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, string channel, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "→", jsonRpcRawMessage, channel); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, string channel, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpServer]", tag, "→", jsonRpcRawMessage, channel); } /// MCP 传输层管理器。 extension(IClientTransportManager manager) { - public void LogRawIn(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); - public void LogRawIn(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "←", jsonRpcRawMessage); - public void LogRawOut(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); - public void LogRawOut(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw(tag, "→", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "←", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "←", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, string channel, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "←", jsonRpcRawMessage, channel); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawIn(string tag, string channel, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "←", jsonRpcRawMessage, channel); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "→", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "→", jsonRpcRawMessage); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, string channel, string jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "→", jsonRpcRawMessage, channel); + /// 记录传输层接收到的原始 JSON-RPC 消息。 + public void LogRawOut(string tag, string channel, JsonRpcMessage jsonRpcRawMessage) => ((IMcpTransportLogger)manager).LogRaw("[McpClient]", tag, "→", jsonRpcRawMessage, channel); } - private static void LogRaw(this IMcpTransportLogger transportLogger, string tag, string direction, string jsonRpcRawMessage) + private static void LogRaw(this IMcpTransportLogger transportLogger, string role, string tag, string direction, string jsonRpcRawMessage, string? channel = null) { if (transportLogger.RawMessageLoggingDetailLevel is not McpTransportRawMessageLoggingDetailLevel.None && transportLogger.Logger.IsEnabled(LoggingLevel.Debug)) @@ -40,11 +64,12 @@ private static void LogRaw(this IMcpTransportLogger transportLogger, string tag, || jsonRpcRawMessage.Length <= TrimmedRawMessageMaxLength ? jsonRpcRawMessage : jsonRpcRawMessage[..TrimmedRawMessageMaxLength] + "...(trimmed)"; - transportLogger.Logger.Debug($"[McpServer]{tag} {direction} {trimmedMessage}"); + var channelPart = channel != null ? $" [via {channel}]" : ""; + transportLogger.Logger.Debug($"{role}{tag} {direction}{channelPart} {trimmedMessage}"); } } - private static void LogRaw(this IMcpTransportLogger transportLogger, string tag, string direction, JsonRpcMessage jsonRpcRawMessage) + private static void LogRaw(this IMcpTransportLogger transportLogger, string role, string tag, string direction, JsonRpcMessage jsonRpcRawMessage, string? channel = null) { if (transportLogger.RawMessageLoggingDetailLevel is not McpTransportRawMessageLoggingDetailLevel.None && transportLogger.Logger.IsEnabled(LoggingLevel.Debug)) @@ -60,7 +85,8 @@ private static void LogRaw(this IMcpTransportLogger transportLogger, string tag, || json.Length <= TrimmedRawMessageMaxLength ? json : json[..TrimmedRawMessageMaxLength] + "...(trimmed)"; - transportLogger.Logger.Debug($"[McpServer]{tag} {direction} {trimmedMessage}"); + var channelPart = channel != null ? $" [via {channel}]" : ""; + transportLogger.Logger.Debug($"{role}{tag} {direction}{channelPart} {trimmedMessage}"); } } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs index 72b98d4..7c4b34c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioServerTransport.cs @@ -92,7 +92,7 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) continue; } - _manager.LogRawIn("Stdio", line); + _manager.LogRawIn("[Stdio]", line); JsonRpcMessage? message; try @@ -182,6 +182,7 @@ public async ValueTask RespondJsonRpcAsync(StreamWriter writer, JsonRpcResponse { try { + manager.LogRawOut("[Stdio]", response); await manager.WriteMessageAsync(writer.BaseStream, response, cancellationToken); await writer.WriteLineAsync(); } From b2f9f655c43c09f0c84acbeefe91a5bc7fd84ee3 Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 10 Apr 2026 14:55:28 +0800 Subject: [PATCH 37/77] =?UTF-8?q?ToString=20=E5=8F=AF=E4=BB=A5=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E6=96=87=E6=9C=AC=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Protocol/Messages/ContentBlock.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs index d518ddc..55903f5 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/ContentBlock.cs @@ -47,6 +47,14 @@ public sealed record TextContentBlock : ContentBlock /// [JsonPropertyName("text")] public required string Text { get; init; } + + /// + /// 输出此文本内容块的文本。 + /// + public override string ToString() + { + return Text; + } } /// @@ -68,6 +76,14 @@ public sealed record ImageContentBlock : ContentBlock /// [JsonPropertyName("mimeType")] public required string MimeType { get; init; } + + /// + /// 输出此图像内容块的文本表示形式,格式为 data URI scheme(data:[<mediatype>][;base64],<data>)。 + /// + public override string ToString() + { + return $"data:{MimeType};base64,{Data}"; + } } /// @@ -89,6 +105,14 @@ public sealed record AudioContentBlock : ContentBlock /// [JsonPropertyName("mimeType")] public required string MimeType { get; init; } + + /// + /// 输出此音频内容块的文本表示形式,格式为 data URI scheme(data:[<mediatype>][;base64],<data>)。 + /// + public override string ToString() + { + return $"data:{MimeType};base64,{Data}"; + } } /// @@ -155,6 +179,14 @@ public sealed record ResourceLinkContentBlock : ContentBlock [JsonPropertyName("size")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? Size { get; init; } + + /// + /// 输出此资源链接内容块的文本表示形式,格式为 data URI scheme(data:[<mediatype>][;base64],<data>)。 + /// + public override string ToString() + { + return $"data:{MimeType ?? "application/octet-stream"};base64,{Uri}"; + } } /// @@ -204,6 +236,14 @@ public abstract record ResourceContents [JsonPropertyName("_meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public JsonElement? Meta { get; init; } + + /// + /// 输出此资源内容的文本表示形式,格式为 data URI scheme(data:[<mediatype>][;base64],<data>)。 + /// + public override string ToString() + { + return $"data:{MimeType ?? "application/octet-stream"};base64,{Uri}"; + } } /// @@ -219,6 +259,14 @@ public sealed record TextResourceContents : ResourceContents /// [JsonPropertyName("text")] public required string Text { get; init; } + + /// + /// 输出此文本资源内容的文本表示形式。 + /// + public override string ToString() + { + return Text; + } } /// From 2f754cad58a01d1e21544fe61d66042e80cf68ee Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 17 Apr 2026 20:33:07 +0800 Subject: [PATCH 38/77] =?UTF-8?q?=E5=85=81=E8=AE=B8=E4=B8=8D=E4=BC=A0?= =?UTF-8?q?=E5=85=A5=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Servers/McpProtocolBridge.cs | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs index 24e6eea..61c2554 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpProtocolBridge.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using DotNetCampus.ModelContextProtocol.CompilerServices; @@ -164,11 +163,7 @@ private async ValueTask HandleRequestAsync( JsonTypeInfo paramsTypeInfo, JsonTypeInfo resultTypeInfo, CancellationToken cancellationToken) { - if (!EnsureParams(request, out var paramsElement, out var errorResponse)) - { - return errorResponse; - } - + var paramsElement = request.Params ?? EmptyObject.JsonElement; var requestParams = paramsElement.Deserialize(paramsTypeInfo); var requestContext = new RequestContext(services, requestParams); @@ -218,26 +213,4 @@ private async ValueTask HandleRequestAsync( } } - private bool EnsureParams(JsonRpcRequest request, - out JsonElement paramsElement, - [NotNullWhen(false)] out JsonRpcResponse? errorResponse) - { - if (request.Params is { } element) - { - paramsElement = element; - errorResponse = null; - return true; - } - errorResponse = new JsonRpcResponse - { - Id = request.Id, - Error = new JsonRpcError - { - Code = (int)JsonRpcErrorCode.InvalidParams, - Message = "The params field is missing or not a valid JSON object.", - }, - }; - paramsElement = default; - return false; - } } From 13ce5387f6697c293cfc24874bfd9ecb2b10ac91 Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 11:13:03 +0800 Subject: [PATCH 39/77] =?UTF-8?q?=E5=AF=B9=E9=BD=90=20MCP=20=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E5=B1=82=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...p-client-transport-implementation-guide.md | 2 +- ...p-server-transport-implementation-guide.md | 7 +++-- docs/knowledge/http-transport-guide.md | 3 ++ ...http-transport-spec-2025-11-25-analysis.md | 5 ++-- .../TouchSocketHttpServerTransport.cs | 12 ++++++-- .../Protocol/ProtocolVersion.cs | 30 +++++++++---------- .../Transports/Http/HttpClientTransport.cs | 16 +++++++++- .../Http/LocalHostHttpServerTransport.cs | 12 ++++++-- .../Transports/Stdio/StdioClientTransport.cs | 21 +++++++++++++ 9 files changed, 79 insertions(+), 29 deletions(-) diff --git a/docs/knowledge/http-client-transport-implementation-guide.md b/docs/knowledge/http-client-transport-implementation-guide.md index 59ac7a1..b25a445 100644 --- a/docs/knowledge/http-client-transport-implementation-guide.md +++ b/docs/knowledge/http-client-transport-implementation-guide.md @@ -23,7 +23,7 @@ - **生命周期**: 长生命周期,在握手成功后启动,直到传输层 Dispose。 - **职责**: 1. 维持一个对 `/mcp` 端点的长连接。 - 2. 设置 header `Mcp-Session-Id` 以标识身份。 + 2. 设置 headers:`Mcp-Session-Id`、`Accept: text/event-stream`、`MCP-Protocol-Version`(协商后必须携带,参考官方规范 §2.7)。 3. 持续解析 SSE 事件 (`message`, `endpoint` 等) 并分发给 Manager。 4. 处理网络异常和自动重连策略。 diff --git a/docs/knowledge/http-server-transport-implementation-guide.md b/docs/knowledge/http-server-transport-implementation-guide.md index 22a9bed..97453ea 100644 --- a/docs/knowledge/http-server-transport-implementation-guide.md +++ b/docs/knowledge/http-server-transport-implementation-guide.md @@ -81,14 +81,15 @@ 1. **协商检查**:检查 `Accept` header 是否包含 `text/event-stream`。若不包含,**必须**返回 `405 Method Not Allowed`(参考官方规范 §2.2.3)。 2. **Session 关联**: * **必须**要求 Header `Mcp-Session-Id`。 - * 如果 ID 不存在,返回 `404 Not Found`。 - * 如果 ID 存在,获取对应的 Session 对象。 + * 如果 Header 不存在(未提供 ID),**必须**返回 `400 Bad Request`(参考官方规范 §2.5.2:服务端应返回 400 而非 404)。 + * 如果 ID 存在,获取对应的 Session 对象。如果 Session 不存在,返回 `404 Not Found`(参考官方规范 §2.5.3)。 3. **建立连接**: * 设置响应 Header `Content-Type: text/event-stream`。 * 设置 `Cache-Control: no-cache`。 * 返回 `200 OK`(此时不要关闭 Response 流)。 4. **发送 Prime Event**: - * 立即发送一个空注释 `:\n\n` 以保活连接。 + * 按照官方规范 §2.1.6 的 SHOULD 建议,应立即发送一个包含事件 ID 和空 data 字段的 SSE 事件,以便客户端设置 `Last-Event-ID` 用于断线重连。 + * 当前实现发送一个空注释 `:\n\n` 作为简化版保活信号(不含事件 ID,不支持断线续传)。如需支持 Resumability,应改为发送带 ID 的真实事件。 5. **保持循环**: * 进入 `await Task.Delay(-1)` 等待,保持 SSE 连接存活(此通路用于未来扩展服务端主动推送,当前暂不发送任何业务消息)。 * 在循环中捕获异常,如果连接断开则正常退出。 diff --git a/docs/knowledge/http-transport-guide.md b/docs/knowledge/http-transport-guide.md index 47665bc..f7e7ecb 100644 --- a/docs/knowledge/http-transport-guide.md +++ b/docs/knowledge/http-transport-guide.md @@ -94,6 +94,9 @@ HandlePostRequestAsync(入口) - [x] 新协议:GET `/mcp` 建立 SSE 保活连接 - [x] 新协议:DELETE `/mcp` 成功终止会话 - [x] 采样(Sampling):服务端通过 POST 响应 SSE 流发起采样请求,客户端 POST 回采样结果 +- [x] POST/GET 请求缺少 `Mcp-Session-Id` 时返回 400(而非 404) +- [x] HTTP 客户端 GET/DELETE 请求均携带 `MCP-Protocol-Version` 头 +- [x] Streamable HTTP 协议版本低于 `2025-03-26` 时 POST 返回 400 - [ ] 路径大小写不敏感 - [ ] 会话不存在时 DELETE 返回 200 OK(幂等性) diff --git a/docs/knowledge/mcp-http-transport-spec-2025-11-25-analysis.md b/docs/knowledge/mcp-http-transport-spec-2025-11-25-analysis.md index e6a6eac..5f9161c 100644 --- a/docs/knowledge/mcp-http-transport-spec-2025-11-25-analysis.md +++ b/docs/knowledge/mcp-http-transport-spec-2025-11-25-analysis.md @@ -40,7 +40,7 @@ * **关键限制**:在纯监听的 GET SSE 流上,**绝不能**发送 JSON-RPC Response(除非是 Resumability 恢复场景),只能发送 Server 端的 Request 或 Notification。 5. **会话管理**: * 在 `InitializeResult` 响应中分配并返回 `Mcp-Session-Id` header。 - * 验证后续请求中的 `Mcp-Session-Id`。如果 ID 无效或过期,返回 404。 + * 验证后续请求中的 `Mcp-Session-Id`。**缺少** ID 时返回 `400 Bad Request`,ID **无效或过期**时返回 `404 Not Found`(参考官方规范 §2.5.2-3)。 * 处理 `DELETE` 请求以终止会话。 6. **协议版本**:检查 `MCP-Protocol-Version` header。 @@ -58,13 +58,14 @@ * 所有 JSON-RPC 消息必须通过 POST 发送到端点。 * Headers 必须包含: * `Accept: application/json, text/event-stream` - * `MCP-Protocol-Version: 2025-11-25` (或协商版本) + * `MCP-Protocol-Version: 2025-11-25` (或协商版本,初始化后携带) * `Mcp-Session-Id: ` (初始化后必带) 2. **接收响应**: * 必须能处理 `application/json`(直接 JSON 响应)。 * 必须能处理 `text/event-stream`(SSE 流式响应)。 3. **会话管理**: * 保存初始化响应中的 `Mcp-Session-Id`。 + * 后续所有请求(包括 GET 监听流和 DELETE 终止会话)都必须携带 `MCP-Protocol-Version` 和 `Mcp-Session-Id` 头(参考官方规范 §2.7)。 * 在不再需要会话时,发送 `DELETE` 请求。 #### 选做 (SHOULD / MAY) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 8d99d14..e1cc61d 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -215,13 +215,17 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, var sessionId = request.Headers.Get(SessionIdHeader).First; if (string.IsNullOrEmpty(sessionId)) { + // 按照 MCP 协议规范 §2.5.2:若服务端要求会话 ID,收到不含 Mcp-Session-Id 的请求时应返回 400。 + // Servers that require a session ID SHOULD respond to requests without an Mcp-Session-Id header with HTTP 400 Bad Request. Log.Warn($"[McpServer][TouchSocket] GET request rejected: Missing Mcp-Session-Id header."); - await context.RespondHttpError(HttpStatusCode.NotFound, "Missing Mcp-Session-Id header"); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); return; } if (!_sessions.TryGetValue(sessionId, out var session)) { + // 按照 MCP 协议规范 §2.5.3:当会话 ID 过期或不存在时,返回 404。 + // The server MUST respond to requests containing that session ID with HTTP 404 Not Found. Log.Warn($"[McpServer][TouchSocket] GET request rejected: Session not found. SessionId={sessionId}"); await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; @@ -275,11 +279,13 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca var request = context.Request; // 协议版本检查 + // 按照 MCP 协议规范 §2.7:Streamable HTTP 传输层最低支持版本为 2025-03-26(该版本引入了 Streamable HTTP 传输层)。 + // If the server receives a request with an invalid or unsupported MCP-Protocol-Version, it MUST respond with 400 Bad Request. var protocolVersion = request.Headers.Get(ProtocolVersionHeader).First; - if (!string.IsNullOrEmpty(protocolVersion) && (ProtocolVersion)protocolVersion < ProtocolVersion.Minimum) + if (!string.IsNullOrEmpty(protocolVersion) && (ProtocolVersion)protocolVersion < ProtocolVersion.StreamableHttpMinimum) { Log.Warn($"[McpServer][TouchSocket] POST request rejected: Unsupported protocol version. Version={protocolVersion}"); - await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.Minimum}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.StreamableHttpMinimum}"); return; } diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs index d16f2b7..93d5288 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs @@ -1,8 +1,7 @@ namespace DotNetCampus.ModelContextProtocol.Protocol; /// -/// 协议版本信息
-/// Protocol version information +/// 协议版本信息 ///
public readonly record struct ProtocolVersion { @@ -20,8 +19,7 @@ public override string ToString() } /// - /// 从字符串隐式转换为
- /// Implicitly converts a string to . + /// 从字符串隐式转换为 。 ///
public static implicit operator ProtocolVersion(string value) { @@ -29,8 +27,7 @@ public static implicit operator ProtocolVersion(string value) } /// - /// 从 隐式转换为字符串。
- /// Implicitly converts a to string. + /// 从 隐式转换为字符串。 ///
public static implicit operator string(ProtocolVersion version) { @@ -38,8 +35,7 @@ public static implicit operator string(ProtocolVersion version) } /// - /// 比较两个协议版本,判断左侧是否大于右侧。
- /// Compares two protocol versions to determine if left is greater than right. + /// 比较两个协议版本,判断左侧是否大于右侧。 ///
public static bool operator >(ProtocolVersion left, ProtocolVersion right) { @@ -47,8 +43,7 @@ public static implicit operator string(ProtocolVersion version) } /// - /// 比较两个协议版本,判断左侧是否小于右侧。
- /// Compares two protocol versions to determine if left is less than right. + /// 比较两个协议版本,判断左侧是否小于右侧。 ///
public static bool operator <(ProtocolVersion left, ProtocolVersion right) { @@ -57,22 +52,25 @@ public static implicit operator string(ProtocolVersion version) private const string CurrentVersion = "2025-11-25"; private const string MinimumVersion = "2024-11-05"; + private const string StreamableHttpMinimumVersion = "2025-03-26"; /// - /// 当前使用的协议版本
- /// The currently used protocol version + /// 当前使用的协议版本 ///
public static readonly ProtocolVersion Current = new(CurrentVersion); /// - /// 大多数功能正常运行所需的最低版本
- /// The minimum version required for most features to work properly + /// 所有已知协议版本中的最低版本 ///
public static readonly ProtocolVersion Minimum = new(MinimumVersion); /// - /// 历史版本列表,按时间倒序排列
- /// List of historical versions, sorted in reverse chronological order + /// Streamable HTTP 传输层所需的最低协议版本(2025-03-26 引入 Streamable HTTP) + ///
+ public static readonly ProtocolVersion StreamableHttpMinimum = new(StreamableHttpMinimumVersion); + + /// + /// 历史版本列表,按时间倒序排列 /// internal static IReadOnlyList HistoryVersions { get; } = [ diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index 4a29d94..03699f5 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -79,6 +79,13 @@ public async ValueTask DisconnectAsync(CancellationToken cancellationToken = def using var request = new HttpRequestMessage(HttpMethod.Delete, _options.ServerUrl); request.Headers.Add("Mcp-Session-Id", _sessionId); + // 按照 MCP 协议规范 §2.7:客户端必须在所有后续请求中携带 MCP-Protocol-Version 头。 + // The client MUST include the MCP-Protocol-Version header on all subsequent requests. + if (!string.IsNullOrEmpty(_protocolVersion)) + { + request.Headers.TryAddWithoutValidation("Mcp-Protocol-Version", _protocolVersion); + } + // 设置较短的超时,避免长时间卡住断开流程 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var response = await _httpClient.SendAsync(request, cts.Token); @@ -232,7 +239,7 @@ await ProcessSseStreamAsync(stream, cancellationToken, $"POST/sse, SessionId={_s var version = pv.GetString(); if (!string.IsNullOrEmpty(version)) { - _logger.Info($"[McpClient][Http] Server protocol version extracted. Source={source}, Version={_protocolVersion}"); + _logger.Info($"[McpClient][Http] Server protocol version extracted. Source={source}, Version={version}"); return version; } } @@ -283,6 +290,13 @@ private async Task ReceiveLoopAsync(CancellationToken token) request.Headers.Add("Mcp-Session-Id", _sessionId); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + // 按照 MCP 协议规范 §2.7:客户端必须在所有后续请求中携带 MCP-Protocol-Version 头。 + // The client MUST include the MCP-Protocol-Version header on all subsequent requests. + if (!string.IsNullOrEmpty(_protocolVersion)) + { + request.Headers.TryAddWithoutValidation("Mcp-Protocol-Version", _protocolVersion); + } + HttpResponseMessage response; try { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 8df41f6..6e54388 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -176,10 +176,12 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat var request = context.Request; // 协议版本检查 + // 按照 MCP 协议规范 §2.7:Streamable HTTP 传输层最低支持版本为 2025-03-26(该版本引入了 Streamable HTTP 传输层)。 + // If the server receives a request with an invalid or unsupported MCP-Protocol-Version, it MUST respond with 400 Bad Request. var protocolVersion = request.Headers[ProtocolVersionHeader]; - if (!string.IsNullOrEmpty(protocolVersion) && protocolVersion < ProtocolVersion.Minimum) + if (!string.IsNullOrEmpty(protocolVersion) && (ProtocolVersion)protocolVersion < ProtocolVersion.StreamableHttpMinimum) { - await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.Minimum}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.StreamableHttpMinimum}"); return; } @@ -405,12 +407,16 @@ private async Task HandleGetRequestAsync(HttpListenerContext context, Cancellati var sessionId = request.Headers[SessionIdHeader]; if (string.IsNullOrEmpty(sessionId)) { - await context.RespondHttpError(HttpStatusCode.NotFound, "Missing Mcp-Session-Id header"); + // 按照 MCP 协议规范 §2.5.2:若服务端要求会话 ID,收到不含 Mcp-Session-Id 的请求时应返回 400。 + // Servers that require a session ID SHOULD respond to requests without an Mcp-Session-Id header with HTTP 400 Bad Request. + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); return; } if (!_sessions.TryGetValue(sessionId, out var session)) { + // 按照 MCP 协议规范 §2.5.3:当会话 ID 过期或不存在时,返回 404。 + // The server MUST respond to requests containing that session ID with HTTP 404 Not Found. await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs index 19127f3..37abae2 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Stdio/StdioClientTransport.cs @@ -42,6 +42,10 @@ public async ValueTask ConnectAsync(CancellationToken cancellationToken = defaul if (process is { } stdio) { _ = RunLoopAsync(stdio, cancellationToken); + // 按照 MCP 协议规范对 STDIO 传输层的要求: + // 客户端不应假设 stderr 输出表示错误条件,但必须持续消耗 stderr, + // 否则当 stderr 管道缓冲区填满后,服务器进程会因写入 stderr 而阻塞。 + _ = RunStderrLoopAsync(stdio, cancellationToken); } _stdio = process; @@ -133,6 +137,23 @@ private async Task RunLoopAsync(StdioProcessInfo stdio, CancellationToken cancel } } + private async Task RunStderrLoopAsync(StdioProcessInfo stdio, CancellationToken cancellationToken) + { + // 持续消耗服务器进程的 stderr 输出,防止管道缓冲区填满导致服务器进程阻塞。 + // 按照 MCP 协议规范对 STDIO 传输层的要求: + // 客户端不应假设 stderr 输出表示错误条件。 + // The client SHOULD NOT assume stderr output indicates error conditions. + while (!cancellationToken.IsCancellationRequested) + { + var line = await stdio.StandardError.ReadLineAsync(cancellationToken); + if (line is null) + { + break; + } + Log.Debug($"[McpClient][Stdio] Server stderr: {line}"); + } + } + [Pure] private Task StartProcessAsync() { From 750bebcff0ae6ad150860d24e2a0d3be41105b3e Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 11:45:24 +0800 Subject: [PATCH 40/77] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8D=8F=E5=95=86?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- docs/plan.md | 501 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 docs/plan.md diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..9b35c76 --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,501 @@ +# 协议兼容与版本协商设计方案 + +## 目标 + +在本库同时兼容以下四个 MCP 协议版本,并且让服务端与客户端都能在 `initialize` 阶段完成明确、可追踪的版本协商: + +- 2025-11-25 +- 2025-06-18 +- 2025-03-26 +- 2024-11-05 + +这里的“兼容”不应只停留在常量或头部校验,而应覆盖以下三个层面: + +1. 生命周期兼容:`initialize` 的版本选择、会话绑定、后续请求校验。 +2. 传输兼容:2025-03-26 及以上的 Streamable HTTP,与 2024-11-05 的旧版 HTTP+SSE 双栈支持。 +3. 消息兼容:按协商后的协议版本裁剪能力、字段和传输行为,而不是默认总发最新模型。 + +## 结论先行 + +结合官方规范与当前仓库实现,最稳妥的方案不是在每个请求里临时判断版本,而是采用“内部统一用最新协议模型,边界层按协商版本做投影”的设计: + +1. 内部协议处理仍以当前主版本 `2025-11-25` 的消息模型和处理器为主。 +2. 在传输层会话上增加“已协商协议版本”和“传输族别”状态,整个会话期间固定使用。 +3. 在 `initialize` 阶段引入统一的版本选择器,负责从客户端请求版本与服务器支持矩阵中选出最终版本,或返回标准错误。 +4. 在 HTTP 入口层同时支持两类协议族: + - Streamable HTTP:`/mcp` + - Legacy HTTP+SSE:`/mcp/sse` 与 `/mcp/messages` +5. 对外发送消息前,根据协商版本做字段裁剪和行为约束;对内接收消息后,必要时做归一化。 + +这套设计的优点是: + +- 对现有 `McpProtocolBridge`、`McpServerRequestHandlers`、工具/资源处理逻辑侵入最小。 +- 能优先覆盖两套 HTTP 传输共有的协商与校验逻辑,避免同一套兼容规则写两遍;对于 2024-11-05 旧版 SSE,可先在 LocalHost 完整落地。 +- 可以渐进式落地,先把版本协商与会话状态做对,再补齐 2024-11-05 旧传输和 2025-03-26 的批处理差异。 + +## 当前仓库现状 + +从现有代码来看,本库已经有一部分“兼容骨架”,但还没有形成完整方案。 + +### 已有基础 + +1. `ProtocolVersion` 已经维护了四个历史版本,并区分了 `Current`、`Minimum` 和 `StreamableHttpMinimum`。 +2. `InitializeRequestParams` 与 `InitializeResult` 已包含 `protocolVersion` 字段。 +3. HTTP 客户端已经会在初始化后缓存服务端返回的协议版本,并把 `Mcp-Protocol-Version` 头带到后续请求里。 +4. `LocalHostHttpServerTransportOptions` 已预留旧版 SSE 兼容选项:`IsCompatibleWithSse`、`SseEndPoint`、`SseMessageEndPoint`。 +5. `ServerTransportManager` 已对“`initialize` 缺失 id”做了旧客户端兼容处理。 + +### 当前缺口 + +1. 服务端 `InitializeAsync` 目前固定返回 `ProtocolVersion.Current`,没有真正执行版本协商。 +2. HTTP 服务端只做了“版本低于 2025-03-26 则拒绝”的硬拦截,没有基于会话的版本持续校验,也没有 2024-11-05 兼容路径。 +3. 客户端初始化时固定发送 `ProtocolVersion.Current`,没有“支持版本集合”概念,也没有自动回退到旧传输。 +4. 当前消息模型默认按最新版本序列化,尚未按协商版本裁剪字段和能力。 +5. 2025-03-26 允许 HTTP POST 承载 JSON-RPC batch,而当前 `ServerTransportManager.ReadMessageAsync` 只处理单条消息对象;如果要宣称完整兼容 2025-03-26,这一项必须补齐。 +6. 旧版 HTTP+SSE 传输尚未实现,`IsCompatibleWithSse` 目前只是配置入口,不是可工作的能力。 +7. TouchSocket 侧的 options 和注释目前明确写着“暂时没考虑兼容旧的 SSE 传输层协议(2024-11-05)”,所以旧协议兼容不能默认视为两套 HTTP 传输同时具备。 + +## 官方规范差异摘要 + +### 版本协商共性 + +四个版本在 lifecycle 上都要求: + +1. `initialize` 必须是握手起点。 +2. 客户端请求中必须声明自己支持的协议版本。 +3. 服务端如果支持该版本,必须回相同版本;否则回自己支持的其他版本。 +4. 后续通信必须遵守协商出的版本与能力。 + +因此,版本协商的核心不是“按字符串比较大小”,而是“从支持矩阵里选一个双方都能执行的 profile”。 + +### 各版本主要差异 + +| 版本 | 传输 | 关键差异 | 实现含义 | +| --- | --- | --- | --- | +| 2025-11-25 | Streamable HTTP | 与 2025-06-18 同族,新增 tasks 等能力与更丰富元数据 | 继续作为内部主模型 | +| 2025-06-18 | Streamable HTTP | 已有 `MCP-Protocol-Version` 头与版本协商,能力集低于 2025-11-25 | 需要能力裁剪 | +| 2025-03-26 | Streamable HTTP | 首次引入 Streamable HTTP;HTTP POST 允许 batch;`initialize` 不得放进 batch | 需要单独处理 batch 兼容 | +| 2024-11-05 | HTTP+SSE | 双端点:SSE 建链 + POST 消息;GET `/sse` 必须先发 `endpoint` 事件 | 需要单独传输实现,不能用现有 `/mcp` 逻辑硬凑 | + +### 对本库最重要的两个事实 + +1. 2025-11-25、2025-06-18、2025-03-26 在“内部应用层处理”上可以共用一套主逻辑,但不能假定它们在“消息外形”和“传输细节”上完全相同。 +2. 2024-11-05 不是简单的 header 差异,而是独立的 HTTP 交互模型,必须作为另一条传输路径实现。 + +## 设计原则 + +### 1. 内部统一,边界投影 + +内部仍然只维护一套主协议处理器和主消息模型,避免为了兼容多个版本把核心逻辑拆成四份。 + +### 2. 协商一次,会话绑定 + +协议版本只在初始化时协商一次,协商结果写入传输层会话。后续所有请求都以会话中的版本为准,不在每次业务处理时重新猜测。 + +### 3. 兼容规则集中管理 + +不要把 `if (version == ...)` 分散在 `McpServerRequestHandlers`、HTTP 传输、客户端、序列化器各处。应当引入独立的“协议 profile/兼容层”。 + +### 4. 两个 HTTP 服务器实现尽量共享同一套规则 + +`LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 当前结构基本对称。版本协商、头部校验、错误模型等规则应该抽到共享组件,避免未来两边行为漂移;但 2024-11-05 的旧版 SSE 兼容更适合先在 LocalHost 落地,再决定是否把 TouchSocket 的 public options 一并扩展。 + +## 总体设计 + +建议引入如下概念。 + +### 1. 协议 Profile + +新增一个不可变的协议描述对象,例如: + +```csharp +internal sealed record McpProtocolProfile( + ProtocolVersion Version, + McpTransportFamily TransportFamily, + bool SupportsStreamableHttp, + bool SupportsLegacyHttpSse, + bool SupportsHttpBatch, + bool SupportsElicitation, + bool SupportsTasks, + bool SupportsImplementationMetadata, + bool RequiresProtocolVersionHeader); +``` + +建议内置四个 profile: + +- `2025-11-25` +- `2025-06-18` +- `2025-03-26` +- `2024-11-05` + +其中: + +- `2025-11-25`、`2025-06-18`、`2025-03-26` 的 `TransportFamily` 都是 `StreamableHttp` +- `2024-11-05` 的 `TransportFamily` 是 `LegacyHttpSse` + +### 2. 版本选择器 + +新增集中式选择器,例如 `McpProtocolVersionSelector`,负责: + +1. 校验客户端请求版本是否是已知版本,或是否允许“未来版本降级”。 +2. 从服务器支持列表中选择最终版本。 +3. 返回标准错误负载(包含 `requested` 与 `supported`)。 + +建议策略: + +1. 如果客户端请求版本被服务器明确支持,直接选该版本。 +2. 如果客户端请求的是“高于当前版本的未知未来版本”,可降级到服务器最新支持版本。 +3. 如果客户端请求的是“已知但未支持”的旧版本,且服务器未启用对应兼容实现,则返回初始化错误,不要谎称支持。 +4. 如果客户端请求的是无效字符串,则返回 `-32602 Unsupported protocol version`,并带 `supported` 列表。 + +### 3. 会话状态对象 + +扩展 `IServerTransportSession` / `ServerTransportSession`,增加至少以下状态: + +- `RequestedProtocolVersion` +- `NegotiatedProtocolVersion` +- `NegotiatedProtocolProfile` +- `TransportFamily` +- `IsInitialized` + +客户端也要在 `HttpClientTransport` 内部保存: + +- `SupportedProtocolVersions` +- `NegotiatedProtocolVersion` +- `NegotiatedTransportFamily` +- `LegacyMessageEndpoint` +- `LastEventId` + +### 4. 消息投影层 + +增加一个协议投影器,例如: + +- `McpProtocolNormalizer`:把旧版输入归一成内部主模型 +- `McpProtocolProjector`:把内部主模型裁剪成目标版本可接受的外形 + +这个组件至少需要覆盖: + +1. `InitializeResult` 的 `protocolVersion` 写回协商结果。 +2. `ServerCapabilities` 的裁剪,例如:低版本不发 `tasks`。 +3. `ClientCapabilities` / `Implementation` 的裁剪,例如:较老版本不发新增元数据字段。 +4. HTTP 传输行为差异,例如 2024-11-05 的 `endpoint` 事件与 `message` 事件。 + +## 服务端实现方案 + +### A. 先抽出共享 HTTP 协议核心 + +建议不要直接在 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 各自硬改,而是先抽一个共享核心,例如: + +- `HttpProtocolRouterCore` +- `LegacyHttpSseSessionCoordinator` +- `StreamableHttpSessionCoordinator` + +两套 HTTP 传输只负责: + +1. 读取请求 +2. 写入响应/SSE +3. 适配底层 HTTP API + +所有“路径分发、版本校验、session 建立、legacy endpoint 拼装、错误模型”都走同一个 core。 + +这样做的原因很直接:当前 LocalHost 与 TouchSocket 两份实现已经基本平行,再把兼容逻辑复制一遍,后续维护成本会明显失控。 + +### B. 初始化协商流程 + +服务端初始化流程建议改成: + +1. 解析 `InitializeRequestParams.ProtocolVersion`。 +2. 调用 `McpProtocolVersionSelector` 选择最终 profile。 +3. 把协商结果写入当前 session。 +4. 基于 profile 构造 `InitializeResult`。 +5. 通过 `McpProtocolProjector` 裁剪响应内容。 +6. 返回 JSON-RPC 响应,同时在 HTTP 场景下写入会话头或旧协议 endpoint 信息。 + +`McpServerRequestHandlers.InitializeAsync` 不建议改成直接知道四个版本的细节,而是: + +1. 继续返回“完整内部结果”。 +2. 在返回前由兼容层做版本投影。 + +这样可以保证业务扩展点仍然简洁,兼容逻辑不污染用户自定义处理器。 + +### C. Streamable HTTP 路径 + +针对 `/mcp`,建议实现以下规则: + +1. `initialize` 之前,允许没有 `Mcp-Protocol-Version` 头。 +2. `initialize` 之后: + - 如果请求头带了版本,则必须与会话协商结果一致。 + - 如果没带头,则优先使用会话中的协商版本;对于无法识别版本的无状态场景,再按规范 fallback 到 `2025-03-26`。 +3. 如果请求头版本无效或服务端不支持,返回 `400 Bad Request`。 +4. `GET /mcp` 与 `POST /mcp` 使用同一套会话版本信息。 +5. `DELETE /mcp` 也要校验会话与版本,而不是只看 `Mcp-Session-Id`。 + +### D. 2025-03-26 的 batch 兼容 + +这是一个容易漏掉但不能忽略的点。 + +如果要对外宣称完整支持 `2025-03-26`,服务端必须补齐: + +1. HTTP POST body 可解析 JSON-RPC batch。 +2. batch 中只要包含 request,就要走 request 响应路径。 +3. `initialize` 不能出现在 batch 中,出现即返回协议错误。 +4. SSE 返回时,要支持“一次请求对应多个响应”的 2025-03-26 语义。 + +如果短期不打算做 batch,那么文档中不能写“已支持 2025-03-26”,只能写“支持其单消息子集”。 + +### E. 2024-11-05 旧版 HTTP+SSE 路径 + +这个版本建议作为独立 transport family 实现,而不是塞进 `/mcp` 的条件分支里。 + +从当前仓库现状看,这部分应当分两步做: + +1. 先在 `LocalHostHttpServerTransport` 完整支持,因为它已经有 `IsCompatibleWithSse` 配置入口。 +2. 再决定是否把相同能力扩展到 TouchSocket;如果不扩展,就必须在文档中明确“TouchSocket 仅支持 Streamable HTTP”。 + +服务端规则应当是: + +1. `GET /mcp/sse` + - 建立 SSE 连接 + - 立即发送 `event: endpoint` + - `data` 为带 `sessionId` 的消息提交地址 +2. `POST /mcp/messages?sessionId=...` + - 接收客户端后续所有消息,包括 `initialize` + - 返回普通 HTTP 状态 +3. 服务端对客户端消息通过 SSE `message` 事件发送 +4. 旧协议路径不要求 `Mcp-Protocol-Version` 头 + +仓库里已有 `IsCompatibleWithSse` 选项,因此服务端 API 设计上建议保持以下形式: + +```csharp +new LocalHostHttpServerTransportOptions +{ + Port = 3001, + EndPoint = "/mcp", + IsCompatibleWithSse = true, +} +``` + +但实现上要真正让这个选项生效。 + +### F. 能力与字段裁剪 + +建议按“目标版本 profile”裁剪以下内容: + +1. `InitializeResult.Capabilities` + - 低版本不发 `tasks` + - 低版本不发高版本才出现的子能力 +2. `InitializeResult.ServerInfo` + - 对较老版本只保留 `name`、`version` + - 较新版本再补 `title`、`description`、`icons`、`websiteUrl` +3. 运行期服务端主动消息 + - 只发送目标版本定义过的方法与字段 + +原则上不要把“旧客户端会忽略未知字段”当作正式兼容策略。那只能算“碰巧能跑”,不算协议级兼容。 + +## 客户端实现方案 + +### A. 客户端配置面 + +建议扩展 `HttpClientTransportOptions`,至少增加: + +- `SupportedProtocolVersions` +- `PreferredProtocolVersion` +- `EnableLegacyHttpSseFallback` +- `AllowFutureVersionDowngrade` + +`McpClientBuilder.WithHttp(...)` 默认值可以是: + +1. 支持 `2025-11-25`、`2025-06-18`、`2025-03-26` +2. 可选启用 `2024-11-05` +3. 默认首选最新版本 + +### B. 初始化策略 + +客户端初始化建议遵循: + +1. 首先按首选版本向 `/mcp` 发送 Streamable HTTP `initialize`。 +2. 若成功,则检查服务端返回的 `protocolVersion` 是否在本地支持列表内。 +3. 若服务端返回本地不支持的版本,立即断开。 +4. 若 POST 初始化失败,且状态码满足规范中的回退条件(`400` / `404` / `405`),再尝试旧版 HTTP+SSE 探测。 + +### C. 旧版 HTTP+SSE 自动探测 + +客户端对服务器 URL 的兼容逻辑建议按规范实现: + +1. 先尝试对用户给出的 URL 执行 Streamable HTTP 初始化 POST。 +2. 如果返回 `400`、`404` 或 `405`,则尝试 GET 建立 SSE。 +3. 如果首个事件是 `endpoint`,认定为 2024-11-05 服务器。 +4. 之后所有客户端消息都发往 `endpoint` 事件给出的地址。 + +这样客户端才能真正做到“用户给一个 URL,库自动识别新旧协议”。 + +### D. 后续请求行为 + +1. Streamable HTTP 模式下:初始化后所有 GET/POST/DELETE 均携带协商出的 `Mcp-Protocol-Version`。 +2. Legacy HTTP+SSE 模式下:不要强行加新协议头,按旧协议 endpoint 与 sessionId 工作。 +3. 如果收到 `404 + Mcp-Session-Id`,按规范重新初始化新会话。 +4. 如果将来实现 resumable stream,则 `Last-Event-ID` 也需要绑定在协商后的 transport family 上。 + +## 推荐代码结构 + +建议按下面的方向拆分代码。 + +### 新增或重构的核心文件 + +| 位置 | 建议改动 | +| --- | --- | +| `src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs` | 增加已知版本判断、版本选择辅助方法,避免外部只靠字符串比较 | +| `src/DotNetCampus.ModelContextProtocol/Protocol/Compatibility/` | 新增 profile、selector、projector、normalizer 等兼容层核心 | +| `src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs` | 增加协商版本、profile、transport family 等会话状态 | +| `src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs` | 落地会话协商状态 | +| `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` | 初始化时使用版本选择器,而不是固定返回 `Current` | +| `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` | 接入共享 HTTP 协议核心,支持 legacy 路由 | +| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` | 共享版本协商与 Streamable HTTP 规则;若要支持 2024-11-05,还需同步扩展 options | +| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` | 若决定支持 2024-11-05,需要补齐 legacy SSE 相关配置面 | +| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs` | 引入支持版本集合、旧版 fallback 和双 transport family 处理 | +| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs` | 暴露客户端兼容配置 | +| `src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs` | 提供更清晰的兼容配置入口 | + +### 尽量不要改动太深的部分 + +以下部分尽量保持稳定,只在边缘接入兼容层: + +- `McpProtocolBridge` +- 工具、资源的业务处理流程 +- Source Generator 生成出来的工具/资源发现机制 + +原因是版本兼容主要发生在传输边界与初始化阶段,不应该把核心业务分发也改成版本驱动。 + +## 分阶段实施计划 + +### 第一阶段:把协商状态做对 + +目标:先让“版本协商”真正成立。 + +1. 引入 `McpProtocolProfile` 与 `McpProtocolVersionSelector` +2. 扩展 session 状态 +3. 修改 `InitializeAsync`,返回协商后的版本 +4. 客户端记录支持版本集合与协商结果 +5. 后续请求头按会话版本校验 + +交付标准: + +- 服务端不再固定回 `2025-11-25` +- 初始化失败时返回标准错误模型 +- 单纯的 Streamable HTTP 版本协商可用 + +### 第二阶段:补齐 Streamable HTTP 的版本差异 + +目标:把 2025-03-26、2025-06-18、2025-11-25 的差异从“字符串兼容”升级为“行为兼容”。 + +1. 按 version profile 裁剪 `InitializeResult` +2. 运行期消息按协商版本裁剪 +3. 补齐 2025-03-26 的 batch 支持,或明确标记为部分兼容 + +交付标准: + +- 不同版本客户端看到的能力集合不同且合理 +- 2025-03-26 的传输差异被准确处理 + +### 第三阶段:实现 2024-11-05 旧版 HTTP+SSE + +目标:真正支持 legacy transport。 + +1. 实现 `/mcp/sse` +2. 实现 `/mcp/messages?sessionId=...` +3. 客户端实现 `endpoint` 事件探测与切换 +4. 打通初始化、工具调用、服务端主动消息全链路 + +交付标准: + +- 新客户端可以自动连接旧服务器 +- 新服务器可选兼容旧客户端 + +### 第四阶段:收敛 API 与文档 + +目标:把兼容能力变成稳定、可理解的公共 API。 + +1. 收敛 builder/options 暴露的兼容配置 +2. 更新 README 与 `docs/knowledge` 说明 +3. 明确声明“哪些版本完全兼容,哪些是部分兼容” + +## 测试计划 + +建议把测试集中放在 `tests/DotNetCampus.ModelContextProtocol.Tests` 下,并优先扩展现有 HTTP/Client/Compliance 测试。 + +### 1. 版本协商测试 + +建议新增或扩展: + +- `Transports/HttpTransportTests.cs` +- `Clients/McpClientTests.cs` +- `Compliance/OfficialServerTests.cs` + +关键用例: + +1. 客户端请求 `2025-11-25`,服务端支持该版本,返回相同版本。 +2. 客户端请求 `2025-11-25`,服务端只支持 `2025-06-18`,返回 `2025-06-18`。 +3. 客户端请求无效版本,服务端返回 `-32602` 与 `supported` 列表。 +4. 初始化后发送与协商版本不一致的 `Mcp-Protocol-Version` 头,服务端返回 `400`。 + +### 2. Streamable HTTP 测试 + +1. `POST /mcp` 初始化返回协商后的 `protocolVersion` 与 `Mcp-Session-Id` +2. `GET /mcp` 读取的会话版本与初始化一致 +3. `DELETE /mcp` 在不同版本下都能正确终止会话 +4. 2025-03-26 batch 的正反向用例 + +### 3. Legacy HTTP+SSE 测试 + +1. `GET /mcp/sse` 首个事件是 `endpoint` +2. `POST /mcp/messages?sessionId=...` 能完成 `initialize` +3. 服务端主动消息通过 SSE `message` 事件送达 +4. 旧协议路径不需要新协议头 + +### 4. 投影测试 + +1. 低版本初始化响应不包含高版本 capability +2. `Implementation` 在不同版本下输出字段不同 +3. 服务端主动请求在低版本下不会发出未定义字段 + +## 风险与取舍 + +### 风险 1:只做协商,不做投影 + +这样最容易“看起来支持多版本,实际上只支持最新消息模型”。短期可跑,长期会在严格客户端上暴露兼容问题。 + +### 风险 2:两套 HTTP 实现分别修改 + +会导致 LocalHost 与 TouchSocket 在路径、头校验、错误码、legacy 行为上逐步漂移。这个风险应该通过共享协议核心消除。 + +### 风险 3:过早把核心业务处理也版本化 + +会让工具、资源、请求分发全线复杂化。正确做法是把版本兼容限制在“传输边界 + 初始化 + 消息投影”三层。 + +### 风险 4:对 2025-03-26 的 batch 支持半做半不做 + +如果不支持,就必须明确写“部分兼容”;否则会造成对外声明与实际行为不一致。 + +## 推荐落地顺序 + +建议按以下顺序推进,而不是一次性铺开: + +1. 先把协商状态、版本选择器和初始化错误模型做完。 +2. 再把 Streamable HTTP 的后续请求校验和消息投影做完。 +3. 然后实现 2024-11-05 的服务端旧路径。 +4. 最后给客户端补自动探测和 legacy fallback。 + +这样可以确保每一步都有明确验收点,不会把“版本协商”和“旧传输兼容”纠缠在一起。 + +## 最终建议 + +如果这项工作要进入正式开发,我建议把目标定为: + +1. 内部维持 `2025-11-25` 主模型不变。 +2. 外围新增一层显式的协议兼容层。 +3. 对 Streamable HTTP 与 Legacy HTTP+SSE 采用双 transport family 设计。 +4. 以测试矩阵驱动声明式支持,而不是仅凭文档描述“理论兼容”。 + +用一句话总结: + +> 本库应采用“最新内核 + 版本 profile + 会话绑定协商 + 边界投影 + 双 HTTP 传输族”的方案实现多版本兼容,而不是在现有传输实现上继续追加零散条件分支。 From cc5ee4edbab746945130dc275999862b88e596ab Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 16:04:25 +0800 Subject: [PATCH 41/77] =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan.md | 573 +++++++------------------ "docs/\346\234\252\346\235\245plan.md" | 501 +++++++++++++++++++++ 2 files changed, 667 insertions(+), 407 deletions(-) create mode 100644 "docs/\346\234\252\346\235\245plan.md" diff --git a/docs/plan.md b/docs/plan.md index 9b35c76..bc86d43 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,501 +1,260 @@ -# 协议兼容与版本协商设计方案 +# 2024-11-05 服务端兼容计划 -## 目标 +本文只规划一件事:让本库的两个 HTTP 服务端传输层兼容 MCP 2024-11-05 的 HTTP with SSE。 -在本库同时兼容以下四个 MCP 协议版本,并且让服务端与客户端都能在 `initialize` 阶段完成明确、可追踪的版本协商: +本期目标限定在: -- 2025-11-25 -- 2025-06-18 -- 2025-03-26 -- 2024-11-05 +1. `LocalHostHttpServerTransport` 支持 2024-11-05。 +2. `TouchSocketHttpServerTransport` 支持 2024-11-05。 +3. 兼容代码尽量独立,避免破坏现有新协议实现。 +4. 新协议热路径的性能与行为保持稳定。 -这里的“兼容”不应只停留在常量或头部校验,而应覆盖以下三个层面: +不在本文范围内的内容不再展开,包括客户端传输层、其他协议版本的统一兼容架构,以及更大范围的版本协商设计。那部分长期方案继续放在 `未来plan.md`。 -1. 生命周期兼容:`initialize` 的版本选择、会话绑定、后续请求校验。 -2. 传输兼容:2025-03-26 及以上的 Streamable HTTP,与 2024-11-05 的旧版 HTTP+SSE 双栈支持。 -3. 消息兼容:按协商后的协议版本裁剪能力、字段和传输行为,而不是默认总发最新模型。 +## 协议边界 -## 结论先行 +2024-11-05 的 HTTP with SSE 有几条直接影响实现的约束: -结合官方规范与当前仓库实现,最稳妥的方案不是在每个请求里临时判断版本,而是采用“内部统一用最新协议模型,边界层按协商版本做投影”的设计: +1. 服务端需要两个端点:一个 SSE 端点,一个普通 HTTP POST 端点。 +2. 客户端连上 SSE 端点后,服务端必须先发送 `endpoint` 事件。 +3. `endpoint` 事件里要告诉客户端后续 POST 的目标地址。 +4. 服务端发往客户端的消息通过 SSE `message` 事件发送,事件数据是 JSON-RPC 消息。 +5. 服务端仍然需要做 `Origin` 校验。 +6. `initialize` 返回的 `protocolVersion` 必须是 `2024-11-05`。 -1. 内部协议处理仍以当前主版本 `2025-11-25` 的消息模型和处理器为主。 -2. 在传输层会话上增加“已协商协议版本”和“传输族别”状态,整个会话期间固定使用。 -3. 在 `initialize` 阶段引入统一的版本选择器,负责从客户端请求版本与服务器支持矩阵中选出最终版本,或返回标准错误。 -4. 在 HTTP 入口层同时支持两类协议族: - - Streamable HTTP:`/mcp` - - Legacy HTTP+SSE:`/mcp/sse` 与 `/mcp/messages` -5. 对外发送消息前,根据协商版本做字段裁剪和行为约束;对内接收消息后,必要时做归一化。 - -这套设计的优点是: - -- 对现有 `McpProtocolBridge`、`McpServerRequestHandlers`、工具/资源处理逻辑侵入最小。 -- 能优先覆盖两套 HTTP 传输共有的协商与校验逻辑,避免同一套兼容规则写两遍;对于 2024-11-05 旧版 SSE,可先在 LocalHost 完整落地。 -- 可以渐进式落地,先把版本协商与会话状态做对,再补齐 2024-11-05 旧传输和 2025-03-26 的批处理差异。 - -## 当前仓库现状 - -从现有代码来看,本库已经有一部分“兼容骨架”,但还没有形成完整方案。 - -### 已有基础 - -1. `ProtocolVersion` 已经维护了四个历史版本,并区分了 `Current`、`Minimum` 和 `StreamableHttpMinimum`。 -2. `InitializeRequestParams` 与 `InitializeResult` 已包含 `protocolVersion` 字段。 -3. HTTP 客户端已经会在初始化后缓存服务端返回的协议版本,并把 `Mcp-Protocol-Version` 头带到后续请求里。 -4. `LocalHostHttpServerTransportOptions` 已预留旧版 SSE 兼容选项:`IsCompatibleWithSse`、`SseEndPoint`、`SseMessageEndPoint`。 -5. `ServerTransportManager` 已对“`initialize` 缺失 id”做了旧客户端兼容处理。 - -### 当前缺口 - -1. 服务端 `InitializeAsync` 目前固定返回 `ProtocolVersion.Current`,没有真正执行版本协商。 -2. HTTP 服务端只做了“版本低于 2025-03-26 则拒绝”的硬拦截,没有基于会话的版本持续校验,也没有 2024-11-05 兼容路径。 -3. 客户端初始化时固定发送 `ProtocolVersion.Current`,没有“支持版本集合”概念,也没有自动回退到旧传输。 -4. 当前消息模型默认按最新版本序列化,尚未按协商版本裁剪字段和能力。 -5. 2025-03-26 允许 HTTP POST 承载 JSON-RPC batch,而当前 `ServerTransportManager.ReadMessageAsync` 只处理单条消息对象;如果要宣称完整兼容 2025-03-26,这一项必须补齐。 -6. 旧版 HTTP+SSE 传输尚未实现,`IsCompatibleWithSse` 目前只是配置入口,不是可工作的能力。 -7. TouchSocket 侧的 options 和注释目前明确写着“暂时没考虑兼容旧的 SSE 传输层协议(2024-11-05)”,所以旧协议兼容不能默认视为两套 HTTP 传输同时具备。 - -## 官方规范差异摘要 - -### 版本协商共性 - -四个版本在 lifecycle 上都要求: - -1. `initialize` 必须是握手起点。 -2. 客户端请求中必须声明自己支持的协议版本。 -3. 服务端如果支持该版本,必须回相同版本;否则回自己支持的其他版本。 -4. 后续通信必须遵守协商出的版本与能力。 - -因此,版本协商的核心不是“按字符串比较大小”,而是“从支持矩阵里选一个双方都能执行的 profile”。 - -### 各版本主要差异 - -| 版本 | 传输 | 关键差异 | 实现含义 | -| --- | --- | --- | --- | -| 2025-11-25 | Streamable HTTP | 与 2025-06-18 同族,新增 tasks 等能力与更丰富元数据 | 继续作为内部主模型 | -| 2025-06-18 | Streamable HTTP | 已有 `MCP-Protocol-Version` 头与版本协商,能力集低于 2025-11-25 | 需要能力裁剪 | -| 2025-03-26 | Streamable HTTP | 首次引入 Streamable HTTP;HTTP POST 允许 batch;`initialize` 不得放进 batch | 需要单独处理 batch 兼容 | -| 2024-11-05 | HTTP+SSE | 双端点:SSE 建链 + POST 消息;GET `/sse` 必须先发 `endpoint` 事件 | 需要单独传输实现,不能用现有 `/mcp` 逻辑硬凑 | - -### 对本库最重要的两个事实 - -1. 2025-11-25、2025-06-18、2025-03-26 在“内部应用层处理”上可以共用一套主逻辑,但不能假定它们在“消息外形”和“传输细节”上完全相同。 -2. 2024-11-05 不是简单的 header 差异,而是独立的 HTTP 交互模型,必须作为另一条传输路径实现。 +除此之外,本文采取“最小兼容”原则:凡是规范没有明确要求必须裁剪的内容,不预先做过度保护;如果后续联调用例证明旧客户端无法接受,再追加有针对性的适配。 ## 设计原则 -### 1. 内部统一,边界投影 - -内部仍然只维护一套主协议处理器和主消息模型,避免为了兼容多个版本把核心逻辑拆成四份。 - -### 2. 协商一次,会话绑定 - -协议版本只在初始化时协商一次,协商结果写入传输层会话。后续所有请求都以会话中的版本为准,不在每次业务处理时重新猜测。 - -### 3. 兼容规则集中管理 - -不要把 `if (version == ...)` 分散在 `McpServerRequestHandlers`、HTTP 传输、客户端、序列化器各处。应当引入独立的“协议 profile/兼容层”。 - -### 4. 两个 HTTP 服务器实现尽量共享同一套规则 - -`LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 当前结构基本对称。版本协商、头部校验、错误模型等规则应该抽到共享组件,避免未来两边行为漂移;但 2024-11-05 的旧版 SSE 兼容更适合先在 LocalHost 落地,再决定是否把 TouchSocket 的 public options 一并扩展。 - -## 总体设计 - -建议引入如下概念。 - -### 1. 协议 Profile - -新增一个不可变的协议描述对象,例如: - -```csharp -internal sealed record McpProtocolProfile( - ProtocolVersion Version, - McpTransportFamily TransportFamily, - bool SupportsStreamableHttp, - bool SupportsLegacyHttpSse, - bool SupportsHttpBatch, - bool SupportsElicitation, - bool SupportsTasks, - bool SupportsImplementationMetadata, - bool RequiresProtocolVersionHeader); -``` - -建议内置四个 profile: - -- `2025-11-25` -- `2025-06-18` -- `2025-03-26` -- `2024-11-05` - -其中: - -- `2025-11-25`、`2025-06-18`、`2025-03-26` 的 `TransportFamily` 都是 `StreamableHttp` -- `2024-11-05` 的 `TransportFamily` 是 `LegacyHttpSse` - -### 2. 版本选择器 - -新增集中式选择器,例如 `McpProtocolVersionSelector`,负责: - -1. 校验客户端请求版本是否是已知版本,或是否允许“未来版本降级”。 -2. 从服务器支持列表中选择最终版本。 -3. 返回标准错误负载(包含 `requested` 与 `supported`)。 - -建议策略: +### 1. 旧协议逻辑独立放置 -1. 如果客户端请求版本被服务器明确支持,直接选该版本。 -2. 如果客户端请求的是“高于当前版本的未知未来版本”,可降级到服务器最新支持版本。 -3. 如果客户端请求的是“已知但未支持”的旧版本,且服务器未启用对应兼容实现,则返回初始化错误,不要谎称支持。 -4. 如果客户端请求的是无效字符串,则返回 `-32602 Unsupported protocol version`,并带 `supported` 列表。 +旧协议兼容尽量拆到单独文件,而不是直接揉进现有核心流程。优先考虑在基础库里新增一组共享类型,由 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 只做轻量接入。 -### 3. 会话状态对象 +如果某段逻辑必须留在原类中,也应拆成单独的 `#region Legacy SSE (2024-11-05)`,避免和现有 Streamable HTTP 路径交错。 -扩展 `IServerTransportSession` / `ServerTransportSession`,增加至少以下状态: +### 2. 新协议热路径不受影响 -- `RequestedProtocolVersion` -- `NegotiatedProtocolVersion` -- `NegotiatedProtocolProfile` -- `TransportFamily` -- `IsInitialized` +`/mcp` 对应的现有 Streamable HTTP 流程继续保持原样。旧协议逻辑只在命中 `/sse` 与 `/messages` 这两个 legacy 端点时才进入。 -客户端也要在 `HttpClientTransport` 内部保存: +目标是让: -- `SupportedProtocolVersions` -- `NegotiatedProtocolVersion` -- `NegotiatedTransportFamily` -- `LegacyMessageEndpoint` -- `LastEventId` +1. 新协议请求不创建 legacy 对象。 +2. 新协议请求不进入 legacy 判断链的深层逻辑。 +3. 开启旧协议兼容后,新协议的可观测行为不变。 -### 4. 消息投影层 +### 3. 两个服务端共用一套旧协议核心 -增加一个协议投影器,例如: +`LocalHost` 和 `TouchSocket` 的底层 HTTP API 不同,但 2024-11-05 的协议规则是相同的。旧协议的 session 管理、SSE 事件格式、消息桥接、初始化适配,应该尽量共用一套实现。 -- `McpProtocolNormalizer`:把旧版输入归一成内部主模型 -- `McpProtocolProjector`:把内部主模型裁剪成目标版本可接受的外形 +这样可以把差异尽量收敛到“如何读请求、如何写响应、如何保持 SSE 连接”这层适配,而不是把同一份兼容逻辑复制两遍。 -这个组件至少需要覆盖: +### 4. 先满足规范硬约束,再看互操作性补丁 -1. `InitializeResult` 的 `protocolVersion` 写回协商结果。 -2. `ServerCapabilities` 的裁剪,例如:低版本不发 `tasks`。 -3. `ClientCapabilities` / `Implementation` 的裁剪,例如:较老版本不发新增元数据字段。 -4. HTTP 传输行为差异,例如 2024-11-05 的 `endpoint` 事件与 `message` 事件。 +本期必须先满足的是旧协议传输形态和 `protocolVersion` 返回值。至于 `serverInfo`、`capabilities` 是否需要额外裁剪,先不要在计划里预设太多规则。 -## 服务端实现方案 +建议的顺序是: -### A. 先抽出共享 HTTP 协议核心 +1. 先让旧客户端按 2024-11-05 的方式成功连上并完成 `initialize`。 +2. 默认尽量复用当前消息模型。 +3. 如果旧客户端对新增字段、能力或消息方法存在兼容问题,再做最小范围的定点适配。 -建议不要直接在 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 各自硬改,而是先抽一个共享核心,例如: +## 建议结构 -- `HttpProtocolRouterCore` -- `LegacyHttpSseSessionCoordinator` -- `StreamableHttpSessionCoordinator` +建议在基础库中增加一组共享的 legacy 组件,例如: -两套 HTTP 传输只负责: +1. `LegacySseSession` +2. `LegacySseEventWriter` +3. `LegacySseRequestRouter` +4. `LegacyInitializeResponseAdapter` +5. `LegacySseEndpointInfo` -1. 读取请求 -2. 写入响应/SSE -3. 适配底层 HTTP API +可以放在如下位置: -所有“路径分发、版本校验、session 建立、legacy endpoint 拼装、错误模型”都走同一个 core。 +1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/` +2. 或 `src/DotNetCampus.ModelContextProtocol/Transports/LegacyHttpSse/` -这样做的原因很直接:当前 LocalHost 与 TouchSocket 两份实现已经基本平行,再把兼容逻辑复制一遍,后续维护成本会明显失控。 +两个服务端传输层只保留薄适配: -### B. 初始化协商流程 +1. 路由识别 `/sse` 与 `/messages` +2. 创建/查找 legacy session +3. 把请求对象转给共享核心 +4. 把共享核心输出写回各自的 HTTP/SSE API -服务端初始化流程建议改成: +如果后续证明这套共享抽象不够顺手,再退一步,把每个传输层中的旧协议部分拆成单独区域,但仍然保持独立方法和独立文件,不直接污染现有 `/mcp` 主流程。 -1. 解析 `InitializeRequestParams.ProtocolVersion`。 -2. 调用 `McpProtocolVersionSelector` 选择最终 profile。 -3. 把协商结果写入当前 session。 -4. 基于 profile 构造 `InitializeResult`。 -5. 通过 `McpProtocolProjector` 裁剪响应内容。 -6. 返回 JSON-RPC 响应,同时在 HTTP 场景下写入会话头或旧协议 endpoint 信息。 +## 兼容入口设计 -`McpServerRequestHandlers.InitializeAsync` 不建议改成直接知道四个版本的细节,而是: +两个服务端都需要支持如下 legacy 端点: -1. 继续返回“完整内部结果”。 -2. 在返回前由兼容层做版本投影。 +1. `GET {EndPoint}/sse` +2. `POST {EndPoint}/messages` -这样可以保证业务扩展点仍然简洁,兼容逻辑不污染用户自定义处理器。 - -### C. Streamable HTTP 路径 - -针对 `/mcp`,建议实现以下规则: - -1. `initialize` 之前,允许没有 `Mcp-Protocol-Version` 头。 -2. `initialize` 之后: - - 如果请求头带了版本,则必须与会话协商结果一致。 - - 如果没带头,则优先使用会话中的协商版本;对于无法识别版本的无状态场景,再按规范 fallback 到 `2025-03-26`。 -3. 如果请求头版本无效或服务端不支持,返回 `400 Bad Request`。 -4. `GET /mcp` 与 `POST /mcp` 使用同一套会话版本信息。 -5. `DELETE /mcp` 也要校验会话与版本,而不是只看 `Mcp-Session-Id`。 - -### D. 2025-03-26 的 batch 兼容 - -这是一个容易漏掉但不能忽略的点。 - -如果要对外宣称完整支持 `2025-03-26`,服务端必须补齐: - -1. HTTP POST body 可解析 JSON-RPC batch。 -2. batch 中只要包含 request,就要走 request 响应路径。 -3. `initialize` 不能出现在 batch 中,出现即返回协议错误。 -4. SSE 返回时,要支持“一次请求对应多个响应”的 2025-03-26 语义。 - -如果短期不打算做 batch,那么文档中不能写“已支持 2025-03-26”,只能写“支持其单消息子集”。 - -### E. 2024-11-05 旧版 HTTP+SSE 路径 - -这个版本建议作为独立 transport family 实现,而不是塞进 `/mcp` 的条件分支里。 - -从当前仓库现状看,这部分应当分两步做: - -1. 先在 `LocalHostHttpServerTransport` 完整支持,因为它已经有 `IsCompatibleWithSse` 配置入口。 -2. 再决定是否把相同能力扩展到 TouchSocket;如果不扩展,就必须在文档中明确“TouchSocket 仅支持 Streamable HTTP”。 - -服务端规则应当是: - -1. `GET /mcp/sse` - - 建立 SSE 连接 - - 立即发送 `event: endpoint` - - `data` 为带 `sessionId` 的消息提交地址 -2. `POST /mcp/messages?sessionId=...` - - 接收客户端后续所有消息,包括 `initialize` - - 返回普通 HTTP 状态 -3. 服务端对客户端消息通过 SSE `message` 事件发送 -4. 旧协议路径不要求 `Mcp-Protocol-Version` 头 - -仓库里已有 `IsCompatibleWithSse` 选项,因此服务端 API 设计上建议保持以下形式: - -```csharp -new LocalHostHttpServerTransportOptions -{ - Port = 3001, - EndPoint = "/mcp", - IsCompatibleWithSse = true, -} -``` +其中: -但实现上要真正让这个选项生效。 +1. `GET /sse` 负责建立 SSE 连接并发送 `endpoint` 事件。 +2. `POST /messages` 负责接收客户端后续发来的 JSON-RPC 消息。 -### F. 能力与字段裁剪 +旧协议的连接时序建议统一为: -建议按“目标版本 profile”裁剪以下内容: +1. 客户端请求 `GET /sse`。 +2. 服务端创建 legacy session。 +3. 服务端返回 `text/event-stream`。 +4. 服务端立即发送 `event: endpoint`。 +5. `data` 中带上当前 session 的消息提交地址。 +6. 客户端之后持续向 `/messages` 发 POST。 +7. 服务端产生的 JSON-RPC 响应和服务端主动消息,都通过 SSE `message` 事件返回。 -1. `InitializeResult.Capabilities` - - 低版本不发 `tasks` - - 低版本不发高版本才出现的子能力 -2. `InitializeResult.ServerInfo` - - 对较老版本只保留 `name`、`version` - - 较新版本再补 `title`、`description`、`icons`、`websiteUrl` -3. 运行期服务端主动消息 - - 只发送目标版本定义过的方法与字段 +这里要注意,2024-11-05 不是当前 `/mcp` 的变体,而是另一套传输形态。因此不要把现有 `application/json` 或 `text/event-stream` 的 `/mcp` 响应策略直接套到 `/messages` 上。 -原则上不要把“旧客户端会忽略未知字段”当作正式兼容策略。那只能算“碰巧能跑”,不算协议级兼容。 +## Session 设计 -## 客户端实现方案 +建议为旧协议使用独立 session 类型,不直接复用现有 `HttpServerTransportSession`。 -### A. 客户端配置面 +这个 session 至少需要承担: -建议扩展 `HttpClientTransportOptions`,至少增加: +1. 维护 `sessionId` +2. 保存 SSE 输出目标 +3. 发送 `endpoint` 事件 +4. 发送 `message` 事件 +5. 感知连接断开并做清理 +6. 把服务端回包与主动消息统一投递到 SSE 通道 -- `SupportedProtocolVersions` -- `PreferredProtocolVersion` -- `EnableLegacyHttpSseFallback` -- `AllowFutureVersionDowngrade` +这样做的好处是: -`McpClientBuilder.WithHttp(...)` 默认值可以是: +1. 旧协议的事件格式不会污染现有 Streamable HTTP session。 +2. `LocalHost` 和 `TouchSocket` 都能围绕同一个 legacy session 抽象做适配。 +3. 后续若要补更多 2024-11-05 细节,也不会牵动 `/mcp` 主流程。 -1. 支持 `2025-11-25`、`2025-06-18`、`2025-03-26` -2. 可选启用 `2024-11-05` -3. 默认首选最新版本 +## initialize 兼容策略 -### B. 初始化策略 +本期对 `initialize` 的处理采用“硬要求最少化、适配后置化”的策略。 -客户端初始化建议遵循: +必须落实的内容: -1. 首先按首选版本向 `/mcp` 发送 Streamable HTTP `initialize`。 -2. 若成功,则检查服务端返回的 `protocolVersion` 是否在本地支持列表内。 -3. 若服务端返回本地不支持的版本,立即断开。 -4. 若 POST 初始化失败,且状态码满足规范中的回退条件(`400` / `404` / `405`),再尝试旧版 HTTP+SSE 探测。 +1. legacy 路径收到 `initialize` 后,返回结果中的 `protocolVersion` 必须是 `2024-11-05`。 +2. legacy 路径下的请求与响应都走旧协议通道,不混用当前 `/mcp` 的头部和会话规则。 -### C. 旧版 HTTP+SSE 自动探测 +初版不必预先做大量字段裁剪。建议先按以下方式处理: -客户端对服务器 URL 的兼容逻辑建议按规范实现: +1. 默认复用当前 `InitializeResult` 的主体生成逻辑。 +2. 在 legacy 路径上仅强制改写 `protocolVersion`。 +3. 其余字段保持现状,除非: + - 规范明确要求不能这样做 + - 旧客户端联调时确实失败 -1. 先尝试对用户给出的 URL 执行 Streamable HTTP 初始化 POST。 -2. 如果返回 `400`、`404` 或 `405`,则尝试 GET 建立 SSE。 -3. 如果首个事件是 `endpoint`,认定为 2024-11-05 服务器。 -4. 之后所有客户端消息都发往 `endpoint` 事件给出的地址。 +如果后续验证发现某些旧客户端无法接受新增字段,再新增一个轻量的 `LegacyInitializeResponseAdapter`,专门做定点裁剪,而不是一开始就铺开一整套通用投影框架。 -这样客户端才能真正做到“用户给一个 URL,库自动识别新旧协议”。 +## 开关与默认值 -### D. 后续请求行为 +当前 `LocalHostHttpServerTransportOptions.IsCompatibleWithSse` 默认为 `false`。这一点不必在计划阶段先写死最终结论,但建议按下面的顺序推进: -1. Streamable HTTP 模式下:初始化后所有 GET/POST/DELETE 均携带协商出的 `Mcp-Protocol-Version`。 -2. Legacy HTTP+SSE 模式下:不要强行加新协议头,按旧协议 endpoint 与 sessionId 工作。 -3. 如果收到 `404 + Mcp-Session-Id`,按规范重新初始化新会话。 -4. 如果将来实现 resumable stream,则 `Last-Event-ID` 也需要绑定在协商后的 transport family 上。 +1. 先让两套服务端都具备旧协议能力。 +2. 让旧协议代码结构上与新协议热路径隔离,做到不开启时几乎无额外代价。 +3. 在兼容模式关闭时,如果命中了明显的旧协议访问特征,就返回更清晰的错误信息,提示开发者开启兼容模式。 +4. 等实现完成并通过回归与性能验证后,再决定默认值是否要调整为 `true`。 -## 推荐代码结构 +TouchSocket 侧也建议补一个对称的开关配置,而不是把兼容逻辑写成始终开启但不可控的状态。 -建议按下面的方向拆分代码。 +## 性能要求 -### 新增或重构的核心文件 +本期兼容旧协议时,性能目标应明确为: -| 位置 | 建议改动 | -| --- | --- | -| `src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs` | 增加已知版本判断、版本选择辅助方法,避免外部只靠字符串比较 | -| `src/DotNetCampus.ModelContextProtocol/Protocol/Compatibility/` | 新增 profile、selector、projector、normalizer 等兼容层核心 | -| `src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs` | 增加协商版本、profile、transport family 等会话状态 | -| `src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs` | 落地会话协商状态 | -| `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` | 初始化时使用版本选择器,而不是固定返回 `Current` | -| `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` | 接入共享 HTTP 协议核心,支持 legacy 路由 | -| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` | 共享版本协商与 Streamable HTTP 规则;若要支持 2024-11-05,还需同步扩展 options | -| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` | 若决定支持 2024-11-05,需要补齐 legacy SSE 相关配置面 | -| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs` | 引入支持版本集合、旧版 fallback 和双 transport family 处理 | -| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs` | 暴露客户端兼容配置 | -| `src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs` | 提供更清晰的兼容配置入口 | +1. 使用新协议连接时,不引入可观测的性能退化。 +2. 使用旧协议连接时,可以接受适度损耗,但不要出现明显的额外对象堆积和不必要复制。 +3. 两套传输层都尽量复用现有 JSON-RPC 读写与应用层桥接能力。 -### 尽量不要改动太深的部分 +实现上建议注意: -以下部分尽量保持稳定,只在边缘接入兼容层: +1. legacy 端点判断尽量前置且浅层。 +2. 只有命中 legacy 路径时才创建 legacy session 与 SSE writer。 +3. 不要让新协议请求进入 legacy 的复杂分支。 -- `McpProtocolBridge` -- 工具、资源的业务处理流程 -- Source Generator 生成出来的工具/资源发现机制 +## 分步实施 -原因是版本兼容主要发生在传输边界与初始化阶段,不应该把核心业务分发也改成版本驱动。 +### 第一步:补齐共享 legacy 核心 -## 分阶段实施计划 +1. 新增 legacy session、event writer、endpoint builder、请求分发等共享类型。 +2. 明确 LocalHost 与 TouchSocket 各自需要实现的薄适配接口。 -### 第一阶段:把协商状态做对 +完成标志: -目标:先让“版本协商”真正成立。 +1. 共享核心不依赖具体 HTTP 实现。 +2. 两个传输层都能接入这套核心。 -1. 引入 `McpProtocolProfile` 与 `McpProtocolVersionSelector` -2. 扩展 session 状态 -3. 修改 `InitializeAsync`,返回协商后的版本 -4. 客户端记录支持版本集合与协商结果 -5. 后续请求头按会话版本校验 +### 第二步:接入 LocalHost -交付标准: +1. 为 `LocalHostHttpServerTransport` 增加 `/sse` 与 `/messages` 路由。 +2. 接入 legacy session 生命周期管理。 +3. 让旧协议响应通过 SSE `message` 事件发送。 -- 服务端不再固定回 `2025-11-25` -- 初始化失败时返回标准错误模型 -- 单纯的 Streamable HTTP 版本协商可用 +完成标志: -### 第二阶段:补齐 Streamable HTTP 的版本差异 +1. `LocalHost` 能完成 `GET /sse` 建链。 +2. `endpoint` 事件格式正确。 +3. `initialize` 与至少一条普通请求能走通。 -目标:把 2025-03-26、2025-06-18、2025-11-25 的差异从“字符串兼容”升级为“行为兼容”。 +### 第三步:接入 TouchSocket -1. 按 version profile 裁剪 `InitializeResult` -2. 运行期消息按协商版本裁剪 -3. 补齐 2025-03-26 的 batch 支持,或明确标记为部分兼容 +1. 让 `TouchSocketHttpServerTransport` 对称支持 `/sse` 与 `/messages`。 +2. 接入同一套 legacy 核心。 +3. 补齐 TouchSocket 对应的配置开关和错误提示。 -交付标准: +完成标志: -- 不同版本客户端看到的能力集合不同且合理 -- 2025-03-26 的传输差异被准确处理 +1. `TouchSocket` 的旧协议行为与 `LocalHost` 对齐。 +2. 两个服务端对旧协议返回一致的传输语义。 -### 第三阶段:实现 2024-11-05 旧版 HTTP+SSE +### 第四步:联调与定点适配 -目标:真正支持 legacy transport。 +1. 用旧客户端验证 `initialize`、普通请求、服务端回包。 +2. 若发现旧客户端对新增字段或消息不兼容,再追加定点裁剪。 +3. 评估兼容开关默认值与错误提示策略。 -1. 实现 `/mcp/sse` -2. 实现 `/mcp/messages?sessionId=...` -3. 客户端实现 `endpoint` 事件探测与切换 -4. 打通初始化、工具调用、服务端主动消息全链路 +完成标志: -交付标准: +1. 旧客户端能连通两个服务端。 +2. 当前新协议路径回归通过。 +3. 若有必要的裁剪,范围被限制在 legacy 适配层中。 -- 新客户端可以自动连接旧服务器 -- 新服务器可选兼容旧客户端 +## 建议改动位置 -### 第四阶段:收敛 API 与文档 +建议优先落在以下文件或相邻新文件中: -目标:把兼容能力变成稳定、可理解的公共 API。 +1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/**` +2. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` +3. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs` +4. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` +5. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` +6. `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` -1. 收敛 builder/options 暴露的兼容配置 -2. 更新 README 与 `docs/knowledge` 说明 -3. 明确声明“哪些版本完全兼容,哪些是部分兼容” +其中,`McpServerRequestHandlers` 只在 legacy `initialize` 的必要适配点上做改动,不建议把大段旧协议逻辑挪进请求处理主流程。 ## 测试计划 -建议把测试集中放在 `tests/DotNetCampus.ModelContextProtocol.Tests` 下,并优先扩展现有 HTTP/Client/Compliance 测试。 - -### 1. 版本协商测试 - -建议新增或扩展: - -- `Transports/HttpTransportTests.cs` -- `Clients/McpClientTests.cs` -- `Compliance/OfficialServerTests.cs` - -关键用例: - -1. 客户端请求 `2025-11-25`,服务端支持该版本,返回相同版本。 -2. 客户端请求 `2025-11-25`,服务端只支持 `2025-06-18`,返回 `2025-06-18`。 -3. 客户端请求无效版本,服务端返回 `-32602` 与 `supported` 列表。 -4. 初始化后发送与协商版本不一致的 `Mcp-Protocol-Version` 头,服务端返回 `400`。 - -### 2. Streamable HTTP 测试 - -1. `POST /mcp` 初始化返回协商后的 `protocolVersion` 与 `Mcp-Session-Id` -2. `GET /mcp` 读取的会话版本与初始化一致 -3. `DELETE /mcp` 在不同版本下都能正确终止会话 -4. 2025-03-26 batch 的正反向用例 - -### 3. Legacy HTTP+SSE 测试 - -1. `GET /mcp/sse` 首个事件是 `endpoint` -2. `POST /mcp/messages?sessionId=...` 能完成 `initialize` -3. 服务端主动消息通过 SSE `message` 事件送达 -4. 旧协议路径不需要新协议头 - -### 4. 投影测试 - -1. 低版本初始化响应不包含高版本 capability -2. `Implementation` 在不同版本下输出字段不同 -3. 服务端主动请求在低版本下不会发出未定义字段 - -## 风险与取舍 - -### 风险 1:只做协商,不做投影 - -这样最容易“看起来支持多版本,实际上只支持最新消息模型”。短期可跑,长期会在严格客户端上暴露兼容问题。 - -### 风险 2:两套 HTTP 实现分别修改 - -会导致 LocalHost 与 TouchSocket 在路径、头校验、错误码、legacy 行为上逐步漂移。这个风险应该通过共享协议核心消除。 - -### 风险 3:过早把核心业务处理也版本化 - -会让工具、资源、请求分发全线复杂化。正确做法是把版本兼容限制在“传输边界 + 初始化 + 消息投影”三层。 - -### 风险 4:对 2025-03-26 的 batch 支持半做半不做 - -如果不支持,就必须明确写“部分兼容”;否则会造成对外声明与实际行为不一致。 - -## 推荐落地顺序 +测试应直接围绕两个服务端传输层展开,避免再扩散到整套多版本兼容话题。 -建议按以下顺序推进,而不是一次性铺开: +优先补在现有 HTTP 测试体系中,并复用已经存在的 `HttpTransportType.LocalHost` / `HttpTransportType.TouchSocket` 双通道测试模式。 -1. 先把协商状态、版本选择器和初始化错误模型做完。 -2. 再把 Streamable HTTP 的后续请求校验和消息投影做完。 -3. 然后实现 2024-11-05 的服务端旧路径。 -4. 最后给客户端补自动探测和 legacy fallback。 +至少覆盖以下用例: -这样可以确保每一步都有明确验收点,不会把“版本协商”和“旧传输兼容”纠缠在一起。 +1. `GET /sse` 成功建立连接,并首先收到 `endpoint` 事件。 +2. `POST /messages` 可以完成 `initialize`。 +3. `initialize` 返回的 `protocolVersion` 为 `2024-11-05`。 +4. 普通工具调用的响应能够通过 SSE `message` 事件送达。 +5. 兼容开关关闭时,访问旧协议端点能得到清晰错误。 +6. 新协议 `/mcp` 的现有行为在两种服务端上都不回退。 +7. 如果后续追加字段裁剪,对应增加回归测试,防止适配范围继续膨胀。 -## 最终建议 +## 验收标准 -如果这项工作要进入正式开发,我建议把目标定为: +本期完成后,应达到: -1. 内部维持 `2025-11-25` 主模型不变。 -2. 外围新增一层显式的协议兼容层。 -3. 对 Streamable HTTP 与 Legacy HTTP+SSE 采用双 transport family 设计。 -4. 以测试矩阵驱动声明式支持,而不是仅凭文档描述“理论兼容”。 +1. 旧客户端可以连接 `LocalHostHttpServerTransport`。 +2. 旧客户端可以连接 `TouchSocketHttpServerTransport`。 +3. 两个服务端都能通过 `/sse` + `/messages` 完成 `initialize` 和至少一条普通请求。 +4. 新协议 `/mcp` 现有能力和性能不出现明显回退。 +5. 旧协议兼容代码主要集中在独立文件或清晰区域内,没有大面积污染现有核心实现。 -用一句话总结: +## 一句话结论 -> 本库应采用“最新内核 + 版本 profile + 会话绑定协商 + 边界投影 + 双 HTTP 传输族”的方案实现多版本兼容,而不是在现有传输实现上继续追加零散条件分支。 +当前阶段最合适的做法,是为 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 增加一套共享的 2024-11-05 legacy 适配层:旧协议逻辑独立放置,两个传输层只做薄接入,`initialize` 先满足硬约束,其余兼容行为按规范和联调结果做最小增量适配。 \ No newline at end of file diff --git "a/docs/\346\234\252\346\235\245plan.md" "b/docs/\346\234\252\346\235\245plan.md" new file mode 100644 index 0000000..9b35c76 --- /dev/null +++ "b/docs/\346\234\252\346\235\245plan.md" @@ -0,0 +1,501 @@ +# 协议兼容与版本协商设计方案 + +## 目标 + +在本库同时兼容以下四个 MCP 协议版本,并且让服务端与客户端都能在 `initialize` 阶段完成明确、可追踪的版本协商: + +- 2025-11-25 +- 2025-06-18 +- 2025-03-26 +- 2024-11-05 + +这里的“兼容”不应只停留在常量或头部校验,而应覆盖以下三个层面: + +1. 生命周期兼容:`initialize` 的版本选择、会话绑定、后续请求校验。 +2. 传输兼容:2025-03-26 及以上的 Streamable HTTP,与 2024-11-05 的旧版 HTTP+SSE 双栈支持。 +3. 消息兼容:按协商后的协议版本裁剪能力、字段和传输行为,而不是默认总发最新模型。 + +## 结论先行 + +结合官方规范与当前仓库实现,最稳妥的方案不是在每个请求里临时判断版本,而是采用“内部统一用最新协议模型,边界层按协商版本做投影”的设计: + +1. 内部协议处理仍以当前主版本 `2025-11-25` 的消息模型和处理器为主。 +2. 在传输层会话上增加“已协商协议版本”和“传输族别”状态,整个会话期间固定使用。 +3. 在 `initialize` 阶段引入统一的版本选择器,负责从客户端请求版本与服务器支持矩阵中选出最终版本,或返回标准错误。 +4. 在 HTTP 入口层同时支持两类协议族: + - Streamable HTTP:`/mcp` + - Legacy HTTP+SSE:`/mcp/sse` 与 `/mcp/messages` +5. 对外发送消息前,根据协商版本做字段裁剪和行为约束;对内接收消息后,必要时做归一化。 + +这套设计的优点是: + +- 对现有 `McpProtocolBridge`、`McpServerRequestHandlers`、工具/资源处理逻辑侵入最小。 +- 能优先覆盖两套 HTTP 传输共有的协商与校验逻辑,避免同一套兼容规则写两遍;对于 2024-11-05 旧版 SSE,可先在 LocalHost 完整落地。 +- 可以渐进式落地,先把版本协商与会话状态做对,再补齐 2024-11-05 旧传输和 2025-03-26 的批处理差异。 + +## 当前仓库现状 + +从现有代码来看,本库已经有一部分“兼容骨架”,但还没有形成完整方案。 + +### 已有基础 + +1. `ProtocolVersion` 已经维护了四个历史版本,并区分了 `Current`、`Minimum` 和 `StreamableHttpMinimum`。 +2. `InitializeRequestParams` 与 `InitializeResult` 已包含 `protocolVersion` 字段。 +3. HTTP 客户端已经会在初始化后缓存服务端返回的协议版本,并把 `Mcp-Protocol-Version` 头带到后续请求里。 +4. `LocalHostHttpServerTransportOptions` 已预留旧版 SSE 兼容选项:`IsCompatibleWithSse`、`SseEndPoint`、`SseMessageEndPoint`。 +5. `ServerTransportManager` 已对“`initialize` 缺失 id”做了旧客户端兼容处理。 + +### 当前缺口 + +1. 服务端 `InitializeAsync` 目前固定返回 `ProtocolVersion.Current`,没有真正执行版本协商。 +2. HTTP 服务端只做了“版本低于 2025-03-26 则拒绝”的硬拦截,没有基于会话的版本持续校验,也没有 2024-11-05 兼容路径。 +3. 客户端初始化时固定发送 `ProtocolVersion.Current`,没有“支持版本集合”概念,也没有自动回退到旧传输。 +4. 当前消息模型默认按最新版本序列化,尚未按协商版本裁剪字段和能力。 +5. 2025-03-26 允许 HTTP POST 承载 JSON-RPC batch,而当前 `ServerTransportManager.ReadMessageAsync` 只处理单条消息对象;如果要宣称完整兼容 2025-03-26,这一项必须补齐。 +6. 旧版 HTTP+SSE 传输尚未实现,`IsCompatibleWithSse` 目前只是配置入口,不是可工作的能力。 +7. TouchSocket 侧的 options 和注释目前明确写着“暂时没考虑兼容旧的 SSE 传输层协议(2024-11-05)”,所以旧协议兼容不能默认视为两套 HTTP 传输同时具备。 + +## 官方规范差异摘要 + +### 版本协商共性 + +四个版本在 lifecycle 上都要求: + +1. `initialize` 必须是握手起点。 +2. 客户端请求中必须声明自己支持的协议版本。 +3. 服务端如果支持该版本,必须回相同版本;否则回自己支持的其他版本。 +4. 后续通信必须遵守协商出的版本与能力。 + +因此,版本协商的核心不是“按字符串比较大小”,而是“从支持矩阵里选一个双方都能执行的 profile”。 + +### 各版本主要差异 + +| 版本 | 传输 | 关键差异 | 实现含义 | +| --- | --- | --- | --- | +| 2025-11-25 | Streamable HTTP | 与 2025-06-18 同族,新增 tasks 等能力与更丰富元数据 | 继续作为内部主模型 | +| 2025-06-18 | Streamable HTTP | 已有 `MCP-Protocol-Version` 头与版本协商,能力集低于 2025-11-25 | 需要能力裁剪 | +| 2025-03-26 | Streamable HTTP | 首次引入 Streamable HTTP;HTTP POST 允许 batch;`initialize` 不得放进 batch | 需要单独处理 batch 兼容 | +| 2024-11-05 | HTTP+SSE | 双端点:SSE 建链 + POST 消息;GET `/sse` 必须先发 `endpoint` 事件 | 需要单独传输实现,不能用现有 `/mcp` 逻辑硬凑 | + +### 对本库最重要的两个事实 + +1. 2025-11-25、2025-06-18、2025-03-26 在“内部应用层处理”上可以共用一套主逻辑,但不能假定它们在“消息外形”和“传输细节”上完全相同。 +2. 2024-11-05 不是简单的 header 差异,而是独立的 HTTP 交互模型,必须作为另一条传输路径实现。 + +## 设计原则 + +### 1. 内部统一,边界投影 + +内部仍然只维护一套主协议处理器和主消息模型,避免为了兼容多个版本把核心逻辑拆成四份。 + +### 2. 协商一次,会话绑定 + +协议版本只在初始化时协商一次,协商结果写入传输层会话。后续所有请求都以会话中的版本为准,不在每次业务处理时重新猜测。 + +### 3. 兼容规则集中管理 + +不要把 `if (version == ...)` 分散在 `McpServerRequestHandlers`、HTTP 传输、客户端、序列化器各处。应当引入独立的“协议 profile/兼容层”。 + +### 4. 两个 HTTP 服务器实现尽量共享同一套规则 + +`LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 当前结构基本对称。版本协商、头部校验、错误模型等规则应该抽到共享组件,避免未来两边行为漂移;但 2024-11-05 的旧版 SSE 兼容更适合先在 LocalHost 落地,再决定是否把 TouchSocket 的 public options 一并扩展。 + +## 总体设计 + +建议引入如下概念。 + +### 1. 协议 Profile + +新增一个不可变的协议描述对象,例如: + +```csharp +internal sealed record McpProtocolProfile( + ProtocolVersion Version, + McpTransportFamily TransportFamily, + bool SupportsStreamableHttp, + bool SupportsLegacyHttpSse, + bool SupportsHttpBatch, + bool SupportsElicitation, + bool SupportsTasks, + bool SupportsImplementationMetadata, + bool RequiresProtocolVersionHeader); +``` + +建议内置四个 profile: + +- `2025-11-25` +- `2025-06-18` +- `2025-03-26` +- `2024-11-05` + +其中: + +- `2025-11-25`、`2025-06-18`、`2025-03-26` 的 `TransportFamily` 都是 `StreamableHttp` +- `2024-11-05` 的 `TransportFamily` 是 `LegacyHttpSse` + +### 2. 版本选择器 + +新增集中式选择器,例如 `McpProtocolVersionSelector`,负责: + +1. 校验客户端请求版本是否是已知版本,或是否允许“未来版本降级”。 +2. 从服务器支持列表中选择最终版本。 +3. 返回标准错误负载(包含 `requested` 与 `supported`)。 + +建议策略: + +1. 如果客户端请求版本被服务器明确支持,直接选该版本。 +2. 如果客户端请求的是“高于当前版本的未知未来版本”,可降级到服务器最新支持版本。 +3. 如果客户端请求的是“已知但未支持”的旧版本,且服务器未启用对应兼容实现,则返回初始化错误,不要谎称支持。 +4. 如果客户端请求的是无效字符串,则返回 `-32602 Unsupported protocol version`,并带 `supported` 列表。 + +### 3. 会话状态对象 + +扩展 `IServerTransportSession` / `ServerTransportSession`,增加至少以下状态: + +- `RequestedProtocolVersion` +- `NegotiatedProtocolVersion` +- `NegotiatedProtocolProfile` +- `TransportFamily` +- `IsInitialized` + +客户端也要在 `HttpClientTransport` 内部保存: + +- `SupportedProtocolVersions` +- `NegotiatedProtocolVersion` +- `NegotiatedTransportFamily` +- `LegacyMessageEndpoint` +- `LastEventId` + +### 4. 消息投影层 + +增加一个协议投影器,例如: + +- `McpProtocolNormalizer`:把旧版输入归一成内部主模型 +- `McpProtocolProjector`:把内部主模型裁剪成目标版本可接受的外形 + +这个组件至少需要覆盖: + +1. `InitializeResult` 的 `protocolVersion` 写回协商结果。 +2. `ServerCapabilities` 的裁剪,例如:低版本不发 `tasks`。 +3. `ClientCapabilities` / `Implementation` 的裁剪,例如:较老版本不发新增元数据字段。 +4. HTTP 传输行为差异,例如 2024-11-05 的 `endpoint` 事件与 `message` 事件。 + +## 服务端实现方案 + +### A. 先抽出共享 HTTP 协议核心 + +建议不要直接在 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 各自硬改,而是先抽一个共享核心,例如: + +- `HttpProtocolRouterCore` +- `LegacyHttpSseSessionCoordinator` +- `StreamableHttpSessionCoordinator` + +两套 HTTP 传输只负责: + +1. 读取请求 +2. 写入响应/SSE +3. 适配底层 HTTP API + +所有“路径分发、版本校验、session 建立、legacy endpoint 拼装、错误模型”都走同一个 core。 + +这样做的原因很直接:当前 LocalHost 与 TouchSocket 两份实现已经基本平行,再把兼容逻辑复制一遍,后续维护成本会明显失控。 + +### B. 初始化协商流程 + +服务端初始化流程建议改成: + +1. 解析 `InitializeRequestParams.ProtocolVersion`。 +2. 调用 `McpProtocolVersionSelector` 选择最终 profile。 +3. 把协商结果写入当前 session。 +4. 基于 profile 构造 `InitializeResult`。 +5. 通过 `McpProtocolProjector` 裁剪响应内容。 +6. 返回 JSON-RPC 响应,同时在 HTTP 场景下写入会话头或旧协议 endpoint 信息。 + +`McpServerRequestHandlers.InitializeAsync` 不建议改成直接知道四个版本的细节,而是: + +1. 继续返回“完整内部结果”。 +2. 在返回前由兼容层做版本投影。 + +这样可以保证业务扩展点仍然简洁,兼容逻辑不污染用户自定义处理器。 + +### C. Streamable HTTP 路径 + +针对 `/mcp`,建议实现以下规则: + +1. `initialize` 之前,允许没有 `Mcp-Protocol-Version` 头。 +2. `initialize` 之后: + - 如果请求头带了版本,则必须与会话协商结果一致。 + - 如果没带头,则优先使用会话中的协商版本;对于无法识别版本的无状态场景,再按规范 fallback 到 `2025-03-26`。 +3. 如果请求头版本无效或服务端不支持,返回 `400 Bad Request`。 +4. `GET /mcp` 与 `POST /mcp` 使用同一套会话版本信息。 +5. `DELETE /mcp` 也要校验会话与版本,而不是只看 `Mcp-Session-Id`。 + +### D. 2025-03-26 的 batch 兼容 + +这是一个容易漏掉但不能忽略的点。 + +如果要对外宣称完整支持 `2025-03-26`,服务端必须补齐: + +1. HTTP POST body 可解析 JSON-RPC batch。 +2. batch 中只要包含 request,就要走 request 响应路径。 +3. `initialize` 不能出现在 batch 中,出现即返回协议错误。 +4. SSE 返回时,要支持“一次请求对应多个响应”的 2025-03-26 语义。 + +如果短期不打算做 batch,那么文档中不能写“已支持 2025-03-26”,只能写“支持其单消息子集”。 + +### E. 2024-11-05 旧版 HTTP+SSE 路径 + +这个版本建议作为独立 transport family 实现,而不是塞进 `/mcp` 的条件分支里。 + +从当前仓库现状看,这部分应当分两步做: + +1. 先在 `LocalHostHttpServerTransport` 完整支持,因为它已经有 `IsCompatibleWithSse` 配置入口。 +2. 再决定是否把相同能力扩展到 TouchSocket;如果不扩展,就必须在文档中明确“TouchSocket 仅支持 Streamable HTTP”。 + +服务端规则应当是: + +1. `GET /mcp/sse` + - 建立 SSE 连接 + - 立即发送 `event: endpoint` + - `data` 为带 `sessionId` 的消息提交地址 +2. `POST /mcp/messages?sessionId=...` + - 接收客户端后续所有消息,包括 `initialize` + - 返回普通 HTTP 状态 +3. 服务端对客户端消息通过 SSE `message` 事件发送 +4. 旧协议路径不要求 `Mcp-Protocol-Version` 头 + +仓库里已有 `IsCompatibleWithSse` 选项,因此服务端 API 设计上建议保持以下形式: + +```csharp +new LocalHostHttpServerTransportOptions +{ + Port = 3001, + EndPoint = "/mcp", + IsCompatibleWithSse = true, +} +``` + +但实现上要真正让这个选项生效。 + +### F. 能力与字段裁剪 + +建议按“目标版本 profile”裁剪以下内容: + +1. `InitializeResult.Capabilities` + - 低版本不发 `tasks` + - 低版本不发高版本才出现的子能力 +2. `InitializeResult.ServerInfo` + - 对较老版本只保留 `name`、`version` + - 较新版本再补 `title`、`description`、`icons`、`websiteUrl` +3. 运行期服务端主动消息 + - 只发送目标版本定义过的方法与字段 + +原则上不要把“旧客户端会忽略未知字段”当作正式兼容策略。那只能算“碰巧能跑”,不算协议级兼容。 + +## 客户端实现方案 + +### A. 客户端配置面 + +建议扩展 `HttpClientTransportOptions`,至少增加: + +- `SupportedProtocolVersions` +- `PreferredProtocolVersion` +- `EnableLegacyHttpSseFallback` +- `AllowFutureVersionDowngrade` + +`McpClientBuilder.WithHttp(...)` 默认值可以是: + +1. 支持 `2025-11-25`、`2025-06-18`、`2025-03-26` +2. 可选启用 `2024-11-05` +3. 默认首选最新版本 + +### B. 初始化策略 + +客户端初始化建议遵循: + +1. 首先按首选版本向 `/mcp` 发送 Streamable HTTP `initialize`。 +2. 若成功,则检查服务端返回的 `protocolVersion` 是否在本地支持列表内。 +3. 若服务端返回本地不支持的版本,立即断开。 +4. 若 POST 初始化失败,且状态码满足规范中的回退条件(`400` / `404` / `405`),再尝试旧版 HTTP+SSE 探测。 + +### C. 旧版 HTTP+SSE 自动探测 + +客户端对服务器 URL 的兼容逻辑建议按规范实现: + +1. 先尝试对用户给出的 URL 执行 Streamable HTTP 初始化 POST。 +2. 如果返回 `400`、`404` 或 `405`,则尝试 GET 建立 SSE。 +3. 如果首个事件是 `endpoint`,认定为 2024-11-05 服务器。 +4. 之后所有客户端消息都发往 `endpoint` 事件给出的地址。 + +这样客户端才能真正做到“用户给一个 URL,库自动识别新旧协议”。 + +### D. 后续请求行为 + +1. Streamable HTTP 模式下:初始化后所有 GET/POST/DELETE 均携带协商出的 `Mcp-Protocol-Version`。 +2. Legacy HTTP+SSE 模式下:不要强行加新协议头,按旧协议 endpoint 与 sessionId 工作。 +3. 如果收到 `404 + Mcp-Session-Id`,按规范重新初始化新会话。 +4. 如果将来实现 resumable stream,则 `Last-Event-ID` 也需要绑定在协商后的 transport family 上。 + +## 推荐代码结构 + +建议按下面的方向拆分代码。 + +### 新增或重构的核心文件 + +| 位置 | 建议改动 | +| --- | --- | +| `src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs` | 增加已知版本判断、版本选择辅助方法,避免外部只靠字符串比较 | +| `src/DotNetCampus.ModelContextProtocol/Protocol/Compatibility/` | 新增 profile、selector、projector、normalizer 等兼容层核心 | +| `src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs` | 增加协商版本、profile、transport family 等会话状态 | +| `src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs` | 落地会话协商状态 | +| `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` | 初始化时使用版本选择器,而不是固定返回 `Current` | +| `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` | 接入共享 HTTP 协议核心,支持 legacy 路由 | +| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` | 共享版本协商与 Streamable HTTP 规则;若要支持 2024-11-05,还需同步扩展 options | +| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` | 若决定支持 2024-11-05,需要补齐 legacy SSE 相关配置面 | +| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs` | 引入支持版本集合、旧版 fallback 和双 transport family 处理 | +| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs` | 暴露客户端兼容配置 | +| `src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs` | 提供更清晰的兼容配置入口 | + +### 尽量不要改动太深的部分 + +以下部分尽量保持稳定,只在边缘接入兼容层: + +- `McpProtocolBridge` +- 工具、资源的业务处理流程 +- Source Generator 生成出来的工具/资源发现机制 + +原因是版本兼容主要发生在传输边界与初始化阶段,不应该把核心业务分发也改成版本驱动。 + +## 分阶段实施计划 + +### 第一阶段:把协商状态做对 + +目标:先让“版本协商”真正成立。 + +1. 引入 `McpProtocolProfile` 与 `McpProtocolVersionSelector` +2. 扩展 session 状态 +3. 修改 `InitializeAsync`,返回协商后的版本 +4. 客户端记录支持版本集合与协商结果 +5. 后续请求头按会话版本校验 + +交付标准: + +- 服务端不再固定回 `2025-11-25` +- 初始化失败时返回标准错误模型 +- 单纯的 Streamable HTTP 版本协商可用 + +### 第二阶段:补齐 Streamable HTTP 的版本差异 + +目标:把 2025-03-26、2025-06-18、2025-11-25 的差异从“字符串兼容”升级为“行为兼容”。 + +1. 按 version profile 裁剪 `InitializeResult` +2. 运行期消息按协商版本裁剪 +3. 补齐 2025-03-26 的 batch 支持,或明确标记为部分兼容 + +交付标准: + +- 不同版本客户端看到的能力集合不同且合理 +- 2025-03-26 的传输差异被准确处理 + +### 第三阶段:实现 2024-11-05 旧版 HTTP+SSE + +目标:真正支持 legacy transport。 + +1. 实现 `/mcp/sse` +2. 实现 `/mcp/messages?sessionId=...` +3. 客户端实现 `endpoint` 事件探测与切换 +4. 打通初始化、工具调用、服务端主动消息全链路 + +交付标准: + +- 新客户端可以自动连接旧服务器 +- 新服务器可选兼容旧客户端 + +### 第四阶段:收敛 API 与文档 + +目标:把兼容能力变成稳定、可理解的公共 API。 + +1. 收敛 builder/options 暴露的兼容配置 +2. 更新 README 与 `docs/knowledge` 说明 +3. 明确声明“哪些版本完全兼容,哪些是部分兼容” + +## 测试计划 + +建议把测试集中放在 `tests/DotNetCampus.ModelContextProtocol.Tests` 下,并优先扩展现有 HTTP/Client/Compliance 测试。 + +### 1. 版本协商测试 + +建议新增或扩展: + +- `Transports/HttpTransportTests.cs` +- `Clients/McpClientTests.cs` +- `Compliance/OfficialServerTests.cs` + +关键用例: + +1. 客户端请求 `2025-11-25`,服务端支持该版本,返回相同版本。 +2. 客户端请求 `2025-11-25`,服务端只支持 `2025-06-18`,返回 `2025-06-18`。 +3. 客户端请求无效版本,服务端返回 `-32602` 与 `supported` 列表。 +4. 初始化后发送与协商版本不一致的 `Mcp-Protocol-Version` 头,服务端返回 `400`。 + +### 2. Streamable HTTP 测试 + +1. `POST /mcp` 初始化返回协商后的 `protocolVersion` 与 `Mcp-Session-Id` +2. `GET /mcp` 读取的会话版本与初始化一致 +3. `DELETE /mcp` 在不同版本下都能正确终止会话 +4. 2025-03-26 batch 的正反向用例 + +### 3. Legacy HTTP+SSE 测试 + +1. `GET /mcp/sse` 首个事件是 `endpoint` +2. `POST /mcp/messages?sessionId=...` 能完成 `initialize` +3. 服务端主动消息通过 SSE `message` 事件送达 +4. 旧协议路径不需要新协议头 + +### 4. 投影测试 + +1. 低版本初始化响应不包含高版本 capability +2. `Implementation` 在不同版本下输出字段不同 +3. 服务端主动请求在低版本下不会发出未定义字段 + +## 风险与取舍 + +### 风险 1:只做协商,不做投影 + +这样最容易“看起来支持多版本,实际上只支持最新消息模型”。短期可跑,长期会在严格客户端上暴露兼容问题。 + +### 风险 2:两套 HTTP 实现分别修改 + +会导致 LocalHost 与 TouchSocket 在路径、头校验、错误码、legacy 行为上逐步漂移。这个风险应该通过共享协议核心消除。 + +### 风险 3:过早把核心业务处理也版本化 + +会让工具、资源、请求分发全线复杂化。正确做法是把版本兼容限制在“传输边界 + 初始化 + 消息投影”三层。 + +### 风险 4:对 2025-03-26 的 batch 支持半做半不做 + +如果不支持,就必须明确写“部分兼容”;否则会造成对外声明与实际行为不一致。 + +## 推荐落地顺序 + +建议按以下顺序推进,而不是一次性铺开: + +1. 先把协商状态、版本选择器和初始化错误模型做完。 +2. 再把 Streamable HTTP 的后续请求校验和消息投影做完。 +3. 然后实现 2024-11-05 的服务端旧路径。 +4. 最后给客户端补自动探测和 legacy fallback。 + +这样可以确保每一步都有明确验收点,不会把“版本协商”和“旧传输兼容”纠缠在一起。 + +## 最终建议 + +如果这项工作要进入正式开发,我建议把目标定为: + +1. 内部维持 `2025-11-25` 主模型不变。 +2. 外围新增一层显式的协议兼容层。 +3. 对 Streamable HTTP 与 Legacy HTTP+SSE 采用双 transport family 设计。 +4. 以测试矩阵驱动声明式支持,而不是仅凭文档描述“理论兼容”。 + +用一句话总结: + +> 本库应采用“最新内核 + 版本 profile + 会话绑定协商 + 边界投影 + 双 HTTP 传输族”的方案实现多版本兼容,而不是在现有传输实现上继续追加零散条件分支。 From bc99044a6ce305ea4eed9a76be4a45045ac04fe4 Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 16:30:02 +0800 Subject: [PATCH 42/77] =?UTF-8?q?=E5=85=BC=E5=AE=B9=202024-11-05=20?= =?UTF-8?q?=E7=89=88=E6=97=A7=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TouchSocketHttpServerTransport.cs | 300 ++++++++++++++++-- .../TouchSocketHttpServerTransportOptions.cs | 31 +- .../Servers/McpServerRequestHandlers.cs | 4 +- .../LegacySseInitializeResponseAdapter.cs | 37 +++ .../Legacy/LegacySseServerTransportSession.cs | 186 +++++++++++ .../Http/Legacy/LegacySseTransportOptions.cs | 22 ++ .../Http/LocalHostHttpServerTransport.cs | 226 ++++++++++++- .../LocalHostHttpServerTransportOptions.cs | 3 +- .../TestMcpFactory.cs | 52 +++ .../Transports/HttpTransportTests.cs | 122 ++++++- 10 files changed, 931 insertions(+), 52 deletions(-) create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseInitializeResponseAdapter.cs create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseServerTransportSession.cs create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseTransportOptions.cs diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index e1cc61d..68be553 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -7,6 +7,7 @@ using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; using DotNetCampus.ModelContextProtocol.Servers; using DotNetCampus.ModelContextProtocol.Transports.Http; +using DotNetCampus.ModelContextProtocol.Transports.Http.Legacy; using TouchSocket.Core; using TouchSocket.Http; using TouchSocket.Sockets; @@ -25,12 +26,8 @@ namespace DotNetCampus.ModelContextProtocol.Transports.TouchSocket; // 如需修改协议逻辑,请同时更新对应方法。 /// -/// 基于 TouchSocket.Http 的 Streamable HTTP 传输层实现。 +/// 基于 TouchSocket.Http 的 HTTP 传输层实现,同时支持 Streamable HTTP 与 2024-11-05 的 HTTP+SSE 兼容模式。 /// -/// -/// TouchSocket.Http 的服务端传输层暂时没考虑兼容旧的 SSE 传输层协议(2024-11-05), -/// 若要兼容 SSE,请使用 MCP 库自带的 传输层。 -/// public class TouchSocketHttpServerTransport : PluginBase, IHttpPlugin, IServerTransport { private const string ProtocolVersionHeader = "MCP-Protocol-Version"; @@ -42,6 +39,7 @@ public class TouchSocketHttpServerTransport : PluginBase, IHttpPlugin, IServerTr private readonly IServerTransportManager _manager; private readonly ITouchSocketHttpServerTransportOptions _options; private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _legacySessions = new(); private CancellationToken _runningCancellationToken; private readonly TouchSocketConfig? _config; @@ -145,43 +143,76 @@ await e.Context.Response private async Task HandleRequestAsync(IHttpSessionClient client, HttpContextEventArgs e) { var context = e.Context; - var endpoint = context.Request.RelativeURL; + var rawEndpoint = context.Request.RelativeURL; + var endpoint = GetPathWithoutQuery(rawEndpoint); + var origin = context.Request.Headers.Get("Origin").First; Log.Debug($"[McpServer][TouchSocket] Received request. Method={context.Request.Method}, Endpoint={endpoint}"); - // 请求安全性验证。 - var validationError = ValidateRequest(context); - if (validationError.HasValue) + if (!ValidateOrigin(origin)) { - var (statusCode, message) = validationError.Value; - Log.Warn($"[McpServer][TouchSocket] Request validation failed. StatusCode={statusCode}, Message={message}"); - await context.Response - .SetStatus(statusCode, message) - .SetContent("") - .AnswerAsync(); + await context.RespondHttpError(HttpStatusCode.Forbidden, "Invalid Origin header"); + return; + } + + var isModernEndpoint = endpoint.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase); + var isLegacySseEndpoint = _options.IsCompatibleWithSse + && endpoint.Equals(_options.SseEndPoint, StringComparison.OrdinalIgnoreCase); + var isLegacyMessageEndpoint = _options.IsCompatibleWithSse + && endpoint.Equals(_options.SseMessageEndPoint, StringComparison.OrdinalIgnoreCase); + + if (isModernEndpoint) + { + var validationError = ValidateRequest(context); + if (validationError.HasValue) + { + var (statusCode, message) = validationError.Value; + Log.Warn($"[McpServer][TouchSocket] Request validation failed. StatusCode={statusCode}, Message={message}"); + await context.Response + .SetStatus(statusCode, message) + .SetContent("") + .AnswerAsync(); + return; + } + } + else if (isLegacyMessageEndpoint && !ValidateContentType(context.Request.ContentType.First)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid Content-Type header. Expected: application/json"); return; } - context.Response.SetCorsHeaders(); + context.Response.SetCorsHeaders(origin); var method = context.Request.Method.ToString(); // Streamable HTTP: 客户端建立连接。 - if (method == "GET" && endpoint.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase)) + if (method == "GET" && isModernEndpoint) { await HandleStreamableHttpConnectionAsync(context, _runningCancellationToken); return; } + if (method == "GET" && isLegacySseEndpoint) + { + await HandleLegacySseConnectionAsync(context, _runningCancellationToken); + return; + } + // Streamable HTTP: 客户端发送消息。 - if (method == "POST" && endpoint.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase)) + if (method == "POST" && isModernEndpoint) { await HandleStreamableHttpMessageAsync(context, _runningCancellationToken); return; } + if (method == "POST" && isLegacyMessageEndpoint) + { + await HandleLegacyMessageAsync(context, _runningCancellationToken); + return; + } + // Streamable HTTP: 客户端关闭连接。 - if (method == "DELETE" && endpoint.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase)) + if (method == "DELETE" && isModernEndpoint) { await HandleStreamableHttpDisconnectionAsync(context); return; @@ -271,6 +302,192 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, } } + /// + /// 2024-11-05 HTTP+SSE: 客户端建立 SSE 连接 (GET /mcp/sse)。 + /// + private async ValueTask HandleLegacySseConnectionAsync(HttpContext context, CancellationToken cancellationToken) + { + var newSessionId = _manager.MakeNewSessionId(); + var session = new LegacySseServerTransportSession(_manager, newSessionId.Id, "[McpServer][LegacySse][TouchSocket]"); + if (!_legacySessions.TryAdd(session.SessionId, session)) + { + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return; + } + + _manager.Add(session); + + context.Response.SetStatus(HttpStatusCode.OK, ""); + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.Add("Cache-Control", "no-cache"); + context.Response.IsChunk = true; + + await using var output = context.Response.CreateWriteStream(); + session.AttachSseStream(output); + + try + { + await output.WriteAsync(PrimeEventBytes, cancellationToken); + await session.WriteEndpointEventAsync(output, BuildLegacyMessageEndpointUri(context.Request, session.SessionId), cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(SseKeepAliveIntervalMs, cancellationToken); + await output.WriteAsync(SseKeepAliveBytes, cancellationToken); + await output.FlushAsync(cancellationToken); + } + } + catch (OperationCanceledException) + { + Log.Info($"[McpServer][LegacySse][TouchSocket] SSE connection ended. SessionId={session.SessionId}"); + } + catch (Exception ex) + { + Log.Info($"[McpServer][LegacySse][TouchSocket] SSE connection ended. SessionId={session.SessionId}, Error={ex.Message}"); + } + finally + { + session.DetachSseStream(output); + _legacySessions.TryRemove(session.SessionId, out _); + await session.DisposeAsync(); + try + { + await context.Response.CompleteChunkAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // 客户端断开时视为正常结束。 + } + } + } + + /// + /// 2024-11-05 HTTP+SSE: 客户端通过 POST 发送消息 (/mcp/messages?sessionId=...)。 + /// + private async ValueTask HandleLegacyMessageAsync(HttpContext context, CancellationToken cancellationToken) + { + JsonRpcMessage? message; + try + { + var bodyBytes = await context.Request.GetContentAsync(); + message = await _manager.ReadMessageAsync(bodyBytes); + } + catch (JsonException) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); + return; + } + + var sessionId = context.Request.Query.Get("sessionId").First; + if (message is not null) + { + _manager.LogRawIn("[LegacySse][TouchSocket]", $"POST, SessionId={sessionId}", message); + } + + switch (message) + { + case JsonRpcResponse jsonRpcResponse: + await HandleLegacyClientResponseAsync(context, sessionId, jsonRpcResponse); + return; + case JsonRpcNotification notification: + await HandleLegacyNotificationAsync(context, sessionId, notification, context.Request, cancellationToken); + return; + case JsonRpcRequest jsonRpcRequest: + await HandleLegacyRpcRequestAsync(context, sessionId, jsonRpcRequest, context.Request, cancellationToken); + return; + default: + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); + return; + } + } + + private async ValueTask HandleLegacyClientResponseAsync(HttpContext context, string? sessionId, JsonRpcResponse response) + { + if (string.IsNullOrEmpty(sessionId)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing sessionId query parameter"); + return; + } + + if (!_legacySessions.TryGetValue(sessionId, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + session.HandleResponseAsync(response); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + + private async ValueTask HandleLegacyNotificationAsync(HttpContext context, string? sessionId, JsonRpcNotification notification, + HttpRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(sessionId)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing sessionId query parameter"); + return; + } + + if (!_legacySessions.TryGetValue(sessionId, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + + private async ValueTask HandleLegacyRpcRequestAsync(HttpContext context, string? sessionId, JsonRpcRequest jsonRpcRequest, + HttpRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(sessionId)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing sessionId query parameter"); + return; + } + + if (!_legacySessions.TryGetValue(sessionId, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + if (!session.HasActiveSseStream) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not connected"); + return; + } + + var response = await _manager.HandleRequestAsync( + jsonRpcRequest, + s => + { + s.AddHttpTransportServices(session.SessionId, request); + s.AddTransportSession(session, Log); + }, + cancellationToken: cancellationToken); + + if (response is not null) + { + if (jsonRpcRequest.Method == RequestMethods.Initialize) + { + response = LegacySseInitializeResponseAdapter.Adapt(response); + } + + await session.WriteMessageEventAsync(response, cancellationToken); + } + + await context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + /// /// Streamable HTTP: 客户端发送消息 (POST /mcp)。 /// @@ -529,6 +746,27 @@ private async ValueTask HandleStreamableHttpDisconnectionAsync(HttpContext conte await context.RespondHttpSuccess(HttpStatusCode.OK); } + private string BuildLegacyMessageEndpointUri(HttpRequest request, string sessionId) + { + var relativeEndpoint = $"{_options.SseMessageEndPoint}?sessionId={Uri.EscapeDataString(sessionId)}"; + var host = request.Headers.Get("Host").First; + if (string.IsNullOrWhiteSpace(host)) + { + return relativeEndpoint; + } + + var origin = request.Headers.Get("Origin").First; + var scheme = origin?.StartsWith("https://", StringComparison.OrdinalIgnoreCase) == true ? "https" : "http"; + return $"{scheme}://{host}{relativeEndpoint}"; + } + + private static string GetPathWithoutQuery(string rawEndpoint) + { + var queryIndex = rawEndpoint.IndexOf('?'); + return queryIndex < 0 ? rawEndpoint : rawEndpoint[..queryIndex]; + } + + /// /// 按照 MCP 官方协议规范对传输层的要求:
/// 服务器必须验证所有传入连接的 Origin 标头,以防止 DNS 重绑定攻击。
@@ -562,12 +800,26 @@ private async ValueTask HandleStreamableHttpDisconnectionAsync(HttpContext conte } } - // 3. DNS 重绑定防护(可选,默认禁用)。 - // Skip remaining validation if DNS rebinding protection is disabled. - return null; } + private static bool ValidateOrigin(string? origin) + { + if (string.IsNullOrWhiteSpace(origin)) + { + return true; + } + + if (origin.Equals("null", StringComparison.Ordinal)) + { + return true; + } + + return origin.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase) + || origin.StartsWith("http://127.0.0.1", StringComparison.Ordinal) + || origin.StartsWith("http://[::1]", StringComparison.Ordinal); + } + /// /// 验证 Content-Type 是否为 application/json。 /// @@ -654,9 +906,9 @@ await context.Response /// /// 设置 CORS 相关的响应头。 /// - internal void SetCorsHeaders() + internal void SetCorsHeaders(string? origin) { - response.Headers.Add("Access-Control-Allow-Origin", "*"); + response.Headers.Add("Access-Control-Allow-Origin", origin ?? "*"); response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Mcp-Protocol-Version"); // 根据 MCP 协议,必须暴露这些头部供客户端访问 diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs index a3ddcfd..58047d4 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs @@ -1,9 +1,10 @@ using System.Diagnostics.CodeAnalysis; using DotNetCampus.ModelContextProtocol.Transports.Http; +using DotNetCampus.ModelContextProtocol.Transports.Http.Legacy; namespace DotNetCampus.ModelContextProtocol.Transports.TouchSocket; -internal interface ITouchSocketHttpServerTransportOptions +internal interface ITouchSocketHttpServerTransportOptions : ILegacySseTransportOptions { /// /// 指定用于传输的端点。 @@ -14,10 +15,6 @@ internal interface ITouchSocketHttpServerTransportOptions /// /// TouchSocket HTTP 服务端传输层配置选项。 /// -/// -/// TouchSocket.Http 的服务端传输层暂时没考虑兼容旧的 SSE 传输层协议(2024-11-05), -/// 若要兼容 SSE,请使用 MCP 库自带的 传输层。 -/// public record TouchSocketHttpServerTransportOptions : ITouchSocketHttpServerTransportOptions { /// @@ -45,15 +42,21 @@ public string EndPoint _ => value.StartsWith('/') ? value : "/" + value, }; } + + /// + [MemberNotNullWhen(true, nameof(SseEndPoint), nameof(SseMessageEndPoint))] + public bool IsCompatibleWithSse { get; init; } + + /// + public string? SseEndPoint => IsCompatibleWithSse ? $"{EndPoint}/sse" : null; + + /// + public string? SseMessageEndPoint => IsCompatibleWithSse ? $"{EndPoint}/messages" : null; } /// /// 从外部传入的 TouchSocket HTTP 服务端传输层配置选项。 /// -/// -/// TouchSocket.Http 的服务端传输层暂时没考虑兼容旧的 SSE 传输层协议(2024-11-05), -/// 若要兼容 SSE,请使用 MCP 库自带的 传输层。 -/// public record ExternalTouchSocketHttpServerTransportOptions : ITouchSocketHttpServerTransportOptions { /// @@ -67,4 +70,14 @@ public string EndPoint _ => value.StartsWith('/') ? value : "/" + value, }; } + + /// + [MemberNotNullWhen(true, nameof(SseEndPoint), nameof(SseMessageEndPoint))] + public bool IsCompatibleWithSse { get; init; } + + /// + public string? SseEndPoint => IsCompatibleWithSse ? $"{EndPoint}/sse" : null; + + /// + public string? SseMessageEndPoint => IsCompatibleWithSse ? $"{EndPoint}/messages" : null; } diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs index 5ee6d3e..68b76c7 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs @@ -5,6 +5,7 @@ using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using DotNetCampus.ModelContextProtocol.Transports; namespace DotNetCampus.ModelContextProtocol.Servers; @@ -66,8 +67,7 @@ public virtual ValueTask InitializeAsync( $"[McpServer][Mcp] Client initializing. ClientName={clientInfo?.Name}, ClientVersion={clientInfo?.Version}, ProtocolVersion={request.Params?.ProtocolVersion}"); // 将客户端能力保存到当前传输层会话,以便后续服务器发起请求(如 sampling)时判断能力。 - var session = (DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession?)request.Services.GetService( - typeof(DotNetCampus.ModelContextProtocol.Transports.IServerTransportSession)); + var session = (IServerTransportSession?)request.Services.GetService(typeof(IServerTransportSession)); if (session is not null && request.Params?.Capabilities is { } capabilities) { session.ConnectedClientCapabilities = capabilities; diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseInitializeResponseAdapter.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseInitializeResponseAdapter.cs new file mode 100644 index 0000000..ba62330 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseInitializeResponseAdapter.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Protocol; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; + +namespace DotNetCampus.ModelContextProtocol.Transports.Http.Legacy; + +/// +/// 统一改写 2024-11-05 旧传输层下的 initialize 响应。 +/// +public static class LegacySseInitializeResponseAdapter +{ + internal static ProtocolVersion ProtocolVersion { get; } = ProtocolVersion.Minimum; + + /// + /// 将 initialize 响应改写为 2024-11-05 旧传输层期望的协议版本。 + /// + public static JsonRpcResponse Adapt(JsonRpcResponse response) + { + if (response.Result is not { ValueKind: JsonValueKind.Object } resultElement) + { + return response; + } + + var initializeResult = resultElement.Deserialize(McpInternalJsonContext.Default.InitializeResult); + if (initializeResult is null) + { + return response; + } + + var adapted = initializeResult with { ProtocolVersion = ProtocolVersion }; + return response with + { + Result = JsonSerializer.SerializeToElement(adapted, McpInternalJsonContext.Default.InitializeResult), + }; + } +} diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseServerTransportSession.cs new file mode 100644 index 0000000..e329c6a --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseServerTransportSession.cs @@ -0,0 +1,186 @@ +using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; + +namespace DotNetCampus.ModelContextProtocol.Transports.Http.Legacy; + +/// +/// 2024-11-05 HTTP+SSE 传输层会话。 +/// +public sealed class LegacySseServerTransportSession : ServerTransportSession +{ + private static readonly ReadOnlyMemory EventEndpointBytes = "event: endpoint\n"u8.ToArray(); + private static readonly ReadOnlyMemory EventMessageBytes = "event: message\n"u8.ToArray(); + private static readonly ReadOnlyMemory DataPrefixBytes = "data: "u8.ToArray(); + private static readonly ReadOnlyMemory NewLineBytes = "\n"u8.ToArray(); + + private readonly IServerTransportManager _manager; + private readonly string _logPrefix; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly CancellationTokenSource _disposeCts = new(); + + private Stream? _sseStream; + + private IMcpLogger Log => _manager.Context.Logger; + + /// + public override string SessionId { get; } + + /// + /// 创建一个 2024-11-05 HTTP+SSE 传输层会话。 + /// + public LegacySseServerTransportSession(IServerTransportManager manager, string sessionId, string logPrefix) + { + _manager = manager; + SessionId = sessionId; + _logPrefix = logPrefix; + } + + /// + /// 绑定当前会话的 SSE 输出流。 + /// + public void AttachSseStream(Stream stream) + { + if (_disposeCts.IsCancellationRequested) + { + throw new ObjectDisposedException(nameof(LegacySseServerTransportSession), "当前会话已被释放,无法绑定 SSE 输出流。"); + } + _sseStream = stream; + } + + /// + /// 当前是否存在可用的 SSE 输出流。 + /// + public bool HasActiveSseStream => _sseStream is not null; + + /// + /// 清除当前会话的 SSE 输出流。 + /// + public void DetachSseStream(Stream stream) + { + if (ReferenceEquals(_sseStream, stream)) + { + _sseStream = null; + } + } + + /// + /// 向客户端发送 endpoint 事件。 + /// + public Task WriteEndpointEventAsync(Stream stream, string endpoint, CancellationToken cancellationToken) + { + return WriteSseEventAsync(stream, EventEndpointBytes, endpoint, cancellationToken); + } + + /// + /// 向当前连接发送 endpoint 事件。 + /// + public Task WriteEndpointEventAsync(string endpoint, CancellationToken cancellationToken) + { + var stream = _sseStream + ?? throw new InvalidOperationException("当前未建立 legacy SSE 连接,无法发送 endpoint 事件。"); + return WriteEndpointEventAsync(stream, endpoint, cancellationToken); + } + + /// + /// 向客户端发送 JSON-RPC message 事件。 + /// + public Task WriteMessageEventAsync(Stream stream, JsonRpcMessage message, CancellationToken cancellationToken) + { + return WriteSseEventAsync(stream, EventMessageBytes, message, cancellationToken); + } + + /// + /// 向当前连接发送 JSON-RPC message 事件。 + /// + public Task WriteMessageEventAsync(JsonRpcMessage message, CancellationToken cancellationToken) + { + var stream = _sseStream + ?? throw new InvalidOperationException("当前未建立 legacy SSE 连接,无法发送 message 事件。"); + return WriteMessageEventAsync(stream, message, cancellationToken); + } + + /// + protected override Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + var stream = _sseStream + ?? throw new InvalidOperationException("当前未建立 legacy SSE 连接,无法发送服务端请求。"); + return WriteMessageEventAsync(stream, request, cancellationToken); + } + + /// + protected override void OnResponseReceived(string id, JsonRpcResponse response) + { + Log.Debug($"{_logPrefix} Received client response for pending legacy request. Id={id}, SessionId={SessionId}"); + } + + /// + protected override void OnUnmatchedResponse(string id, JsonRpcResponse response) + { + Log.Warn($"{_logPrefix} Received unmatched client response on legacy SSE session. Id={id}, SessionId={SessionId}"); + } + + /// + public override async ValueTask DisposeAsync() + { + if (_disposeCts.IsCancellationRequested) + { + return; + } + +#if NET8_0_OR_GREATER + await _disposeCts.CancelAsync(); +#else + await Task.Yield(); + _disposeCts.Cancel(); +#endif + CancelAllPendingRequests(); + _sseStream = null; + _disposeCts.Dispose(); + _writeLock.Dispose(); + } + + private async Task WriteSseEventAsync(Stream stream, ReadOnlyMemory eventHeader, string textPayload, CancellationToken cancellationToken) + { + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await stream.WriteAsync(eventHeader, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(DataPrefixBytes, cancellationToken).ConfigureAwait(false); + await using (var writer = new StreamWriter(stream, leaveOpen: true)) + { + await writer.WriteAsync(textPayload).ConfigureAwait(false); + await writer.FlushAsync( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ).ConfigureAwait(false); + } + await stream.WriteAsync(NewLineBytes, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(NewLineBytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + private async Task WriteSseEventAsync(Stream stream, ReadOnlyMemory eventHeader, JsonRpcMessage message, CancellationToken cancellationToken) + { + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await stream.WriteAsync(eventHeader, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(DataPrefixBytes, cancellationToken).ConfigureAwait(false); + await _manager.WriteMessageAsync(stream, message, cancellationToken).ConfigureAwait(false); + _manager.LogRawOut(_logPrefix, $"Legacy/message, SessionId={SessionId}", message); + await stream.WriteAsync(NewLineBytes, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(NewLineBytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } +} diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseTransportOptions.cs new file mode 100644 index 0000000..a414593 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/LegacySseTransportOptions.cs @@ -0,0 +1,22 @@ +namespace DotNetCampus.ModelContextProtocol.Transports.Http.Legacy; + +/// +/// 2024-11-05 HTTP+SSE 兼容配置。 +/// +public interface ILegacySseTransportOptions +{ + /// + /// 是否启用 2024-11-05 旧协议兼容。 + /// + bool IsCompatibleWithSse { get; } + + /// + /// 旧协议 SSE 端点。 + /// + string? SseEndPoint { get; } + + /// + /// 旧协议消息投递端点。 + /// + string? SseMessageEndPoint { get; } +} \ No newline at end of file diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 6e54388..eca856d 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -6,6 +6,7 @@ using DotNetCampus.ModelContextProtocol.Hosting.Services; using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using DotNetCampus.ModelContextProtocol.Transports.Http.Legacy; namespace DotNetCampus.ModelContextProtocol.Transports.Http; @@ -24,6 +25,7 @@ public class LocalHostHttpServerTransport : IServerTransport private readonly LocalHostHttpServerTransportOptions _options; private readonly HttpListener _listener = new(); private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _legacySessions = new(); /// /// 初始化 类的新实例。 @@ -127,7 +129,12 @@ private async Task HandleRequestAsync(HttpListenerContext context, CancellationT // 1. 路径检查 var requestPath = request.Url?.AbsolutePath ?? "/"; - if (!requestPath.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase)) + var isModernEndpoint = requestPath.Equals(_options.EndPoint, StringComparison.OrdinalIgnoreCase); + var isLegacySseEndpoint = _options.IsCompatibleWithSse + && requestPath.Equals(_options.SseEndPoint, StringComparison.OrdinalIgnoreCase); + var isLegacyMessageEndpoint = _options.IsCompatibleWithSse + && requestPath.Equals(_options.SseMessageEndPoint, StringComparison.OrdinalIgnoreCase); + if (!isModernEndpoint && !isLegacySseEndpoint && !isLegacyMessageEndpoint) { await context.RespondHttpError(HttpStatusCode.NotFound); return; @@ -156,13 +163,45 @@ private async Task HandleRequestAsync(HttpListenerContext context, CancellationT switch (request.HttpMethod) { case "POST": - await HandlePostRequestAsync(context, cancellationToken); + if (isLegacyMessageEndpoint) + { + await HandleLegacyPostRequestAsync(context, cancellationToken); + } + else if (isModernEndpoint) + { + await HandlePostRequestAsync(context, cancellationToken); + } + else + { + response.AddHeader("Allow", "GET, OPTIONS"); + await context.RespondHttpError(HttpStatusCode.MethodNotAllowed); + } break; case "GET": - await HandleGetRequestAsync(context, cancellationToken); + if (isLegacySseEndpoint) + { + await HandleLegacyGetRequestAsync(context, cancellationToken); + } + else if (isModernEndpoint) + { + await HandleGetRequestAsync(context, cancellationToken); + } + else + { + response.AddHeader("Allow", "POST, DELETE, OPTIONS"); + await context.RespondHttpError(HttpStatusCode.MethodNotAllowed); + } break; case "DELETE": - await HandleDeleteRequestAsync(context); + if (isModernEndpoint) + { + await HandleDeleteRequestAsync(context); + } + else + { + response.AddHeader("Allow", "POST, OPTIONS"); + await context.RespondHttpError(HttpStatusCode.MethodNotAllowed); + } break; default: response.AddHeader("Allow", "POST, GET, DELETE, OPTIONS"); @@ -171,6 +210,174 @@ private async Task HandleRequestAsync(HttpListenerContext context, CancellationT } } + private async Task HandleLegacyGetRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + var newSessionId = _manager.MakeNewSessionId(); + var session = new LegacySseServerTransportSession(_manager, newSessionId.Id, "[McpServer][LegacySse]"); + if (!_legacySessions.TryAdd(session.SessionId, session)) + { + await context.RespondHttpError(HttpStatusCode.InternalServerError, "Session ID collision"); + return; + } + + _manager.Add(session); + + context.Response.StatusCode = (int)HttpStatusCode.OK; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers["Cache-Control"] = "no-cache"; + + var output = context.Response.OutputStream; + session.AttachSseStream(output); + + try + { + await output.WriteAsync(PrimeEventBytes, cancellationToken); + await session.WriteEndpointEventAsync(output, BuildLegacyMessageEndpointUri(context.Request, session.SessionId), cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(SseKeepAliveIntervalMs, cancellationToken); + await output.WriteAsync(SseKeepAliveBytes, cancellationToken); + await output.FlushAsync(cancellationToken); + } + } + catch (OperationCanceledException) + { + Log.Info($"[McpServer][LegacySse] SSE connection ended. SessionId={session.SessionId}"); + } + catch (Exception ex) + { + Log.Info($"[McpServer][LegacySse] SSE connection ended. SessionId={session.SessionId}, Error={ex.Message}"); + } + finally + { + session.DetachSseStream(output); + _legacySessions.TryRemove(session.SessionId, out _); + await session.DisposeAsync(); + context.Response.SafeClose(); + } + } + + private async Task HandleLegacyPostRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + JsonRpcMessage? message; + try + { + message = await _manager.ReadMessageAsync(context.Request.InputStream); + } + catch (JsonException) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); + return; + } + catch + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Failed to read request body"); + return; + } + + var sessionId = context.Request.QueryString["sessionId"]; + if (message is not null) + { + _manager.LogRawIn("[LegacySse]", $"POST, SessionId={sessionId}", message); + } + + switch (message) + { + case JsonRpcResponse jsonRpcResponse: + await HandleLegacyClientResponseAsync(context, sessionId, jsonRpcResponse); + return; + case JsonRpcNotification notification: + await HandleLegacyNotificationAsync(context, sessionId, notification, cancellationToken); + return; + case JsonRpcRequest jsonRpcRequest: + await HandleLegacyRpcRequestAsync(context, sessionId, jsonRpcRequest, cancellationToken); + return; + default: + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); + return; + } + } + + private async Task HandleLegacyClientResponseAsync(HttpListenerContext context, string? sessionId, JsonRpcResponse response) + { + if (string.IsNullOrEmpty(sessionId)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing sessionId query parameter"); + return; + } + + if (!_legacySessions.TryGetValue(sessionId, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + session.HandleResponseAsync(response); + context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + + private async Task HandleLegacyNotificationAsync(HttpListenerContext context, string? sessionId, JsonRpcNotification notification, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(sessionId)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing sessionId query parameter"); + return; + } + + if (!_legacySessions.TryGetValue(sessionId, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + s => s.AddTransportSession(session, Log), + cancellationToken); + context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + + private async Task HandleLegacyRpcRequestAsync(HttpListenerContext context, string? sessionId, JsonRpcRequest jsonRpcRequest, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(sessionId)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing sessionId query parameter"); + return; + } + + if (!_legacySessions.TryGetValue(sessionId, out var session)) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); + return; + } + + if (!session.HasActiveSseStream) + { + await context.RespondHttpError(HttpStatusCode.NotFound, "Session not connected"); + return; + } + + var response = await _manager.HandleRequestAsync( + jsonRpcRequest, + s => s.AddTransportSession(session, Log), + cancellationToken); + + if (response is not null) + { + if (jsonRpcRequest.Method == RequestMethods.Initialize) + { + response = LegacySseInitializeResponseAdapter.Adapt(response); + } + + await session.WriteMessageEventAsync(response, cancellationToken); + } + + context.RespondHttpSuccess(HttpStatusCode.Accepted); + } + private async Task HandlePostRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) { var request = context.Request; @@ -471,6 +678,17 @@ private async Task HandleDeleteRequestAsync(HttpListenerContext context) context.RespondHttpSuccess(HttpStatusCode.OK); } + private string BuildLegacyMessageEndpointUri(HttpListenerRequest request, string sessionId) + { + var relativeEndpoint = $"{_options.SseMessageEndPoint}?sessionId={Uri.EscapeDataString(sessionId)}"; + if (request.Url is { } requestUrl) + { + return new Uri(requestUrl, relativeEndpoint).AbsoluteUri; + } + + return relativeEndpoint; + } + private static bool ValidateOrigin(string? origin) { // 允许空 Origin (非浏览器) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs index 1cf6e6a..663787e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs @@ -1,11 +1,12 @@ using System.Diagnostics.CodeAnalysis; +using DotNetCampus.ModelContextProtocol.Transports.Http.Legacy; namespace DotNetCampus.ModelContextProtocol.Transports.Http; /// /// HTTP 传输层配置选项。 /// -public record LocalHostHttpServerTransportOptions +public record LocalHostHttpServerTransportOptions : ILegacySseTransportOptions { /// /// 指定用于传输的端口号。 diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs index 47a700d..c368e24 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs @@ -47,6 +47,40 @@ public async ValueTask CreateSimpleHttpAsync(HttpTransportTyp return await CreateHttpAsync(httpTransportType, t => t.WithTool(() => new SimpleTool())); } + /// + /// 创建一个启用 2024-11-05 HTTP+SSE 兼容模式的 HTTP 服务端测试包。 + /// + public async ValueTask CreateLegacyHttpAsync(HttpTransportType httpTransportType) + { + var port = Interlocked.Increment(ref _port); + var mcpServerBuilder = new McpServerBuilder("TestMcpServer", "1.0.0") + .WithLogger(DefaultLogger) + .WithTools(t => t.WithTool(() => new SimpleTool())); + + mcpServerBuilder = httpTransportType switch + { + HttpTransportType.LocalHost => mcpServerBuilder.WithLocalHostHttp(new LocalHostHttpServerTransportOptions + { + Port = port, + EndPoint = "mcp", + IsCompatibleWithSse = true, + }), + HttpTransportType.TouchSocket => mcpServerBuilder.WithTouchSocketHttp(new TouchSocketHttpServerTransportOptions + { + Listen = [$"127.0.0.1:{port}", $"[::1]:{port}"], + EndPoint = "mcp", + IsCompatibleWithSse = true, + }), + _ => throw new NotSupportedException($"不支持的传输层类型:{httpTransportType}"), + }; + + var server = mcpServerBuilder.Build(); + server.EnableDebugMode(); + await server.StartAsync(CancellationToken.None); + + return new LegacyHttpTestingPackage(server, new Uri($"http://127.0.0.1:{port}/mcp", UriKind.Absolute)); + } + /// /// 创建一个仅包含 transient 计数工具的 HTTP 传输 MCP 测试包。 /// 用于验证 CreationMode.Transient 的实例语义。 @@ -213,6 +247,24 @@ public async ValueTask DisposeAsync() } } +public class LegacyHttpTestingPackage : IAsyncDisposable +{ + public LegacyHttpTestingPackage(McpServer server, Uri endpoint) + { + Server = server; + Endpoint = endpoint; + } + + public McpServer Server { get; } + + public Uri Endpoint { get; } + + public ValueTask DisposeAsync() + { + return new ValueTask(Server.StopAsync()); + } +} + public enum HttpTransportType { LocalHost, diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs index be4a185..2575af5 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs @@ -1,3 +1,7 @@ +using System.Net; +using System.Text; +using System.Text.Json; + namespace DotNetCampus.ModelContextProtocol.Tests.Transports; /// @@ -6,24 +10,53 @@ namespace DotNetCampus.ModelContextProtocol.Tests.Transports; [TestClass] public class HttpTransportTests { - // TODO: 需要实现更底层的 HTTP 请求测试,不通过 McpClient 而是直接发送 HTTP 请求 - [TestMethod("Post_NoSessionId: 旧协议下不带 sessionId 应返回错误")] - public async Task Post_NoSessionId() + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task Post_NoSessionId(HttpTransportType type) { - // 此测试需要直接发送 HTTP 请求而非通过 McpClient - // 目前作为占位符 - await Task.CompletedTask; - Assert.Inconclusive("需要直接 HTTP 请求测试,待基础设施完善后实现。"); + await using var package = await TestMcpFactory.Shared.CreateLegacyHttpAsync(type); + using var client = CreateHttpClient(); + + using var response = await client.PostAsync( + new Uri($"{package.Endpoint}/messages", UriKind.Absolute), + CreateInitializeRequestContent()); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); } [TestMethod("Sse_EndpointEvent: 旧协议 SSE 连接应首先收到 endpoint 事件")] - public async Task Sse_EndpointEvent() + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task Sse_EndpointEvent(HttpTransportType type) { - // 此测试需要直接监听 SSE 流 - // 目前作为占位符 - await Task.CompletedTask; - Assert.Inconclusive("需要直接 SSE 流监听测试,待基础设施完善后实现。"); + await using var package = await TestMcpFactory.Shared.CreateLegacyHttpAsync(type); + using var client = CreateHttpClient(); + using var sseRequest = new HttpRequestMessage(HttpMethod.Get, new Uri($"{package.Endpoint}/sse", UriKind.Absolute)); + sseRequest.Headers.Accept.ParseAdd("text/event-stream"); + + using var sseResponse = await client.SendAsync(sseRequest, HttpCompletionOption.ResponseHeadersRead); + Assert.AreEqual(HttpStatusCode.OK, sseResponse.StatusCode); + Assert.IsTrue(sseResponse.Content.Headers.ContentType?.MediaType?.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) == true); + + using var sseStream = await sseResponse.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(sseStream, Encoding.UTF8, leaveOpen: true); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var endpointEvent = await ReadNextSseEventAsync(reader, timeoutCts.Token); + Assert.AreEqual("endpoint", endpointEvent.EventName); + StringAssert.Contains(endpointEvent.Data, "/mcp/messages?sessionId="); + + var messageEndpoint = ResolveEndpoint(package.Endpoint, endpointEvent.Data); + using var initializeResponse = await client.PostAsync(messageEndpoint, CreateInitializeRequestContent(), timeoutCts.Token); + Assert.AreEqual(HttpStatusCode.Accepted, initializeResponse.StatusCode); + + var messageEvent = await ReadNextSseEventAsync(reader, timeoutCts.Token); + Assert.AreEqual("message", messageEvent.EventName); + + using var document = JsonDocument.Parse(messageEvent.Data); + Assert.AreEqual("2.0", document.RootElement.GetProperty("jsonrpc").GetString()); + Assert.AreEqual("2024-11-05", document.RootElement.GetProperty("result").GetProperty("protocolVersion").GetString()); } [TestMethod("Delete_TerminateSession: 新协议 DELETE 请求应终止会话")] @@ -44,4 +77,69 @@ public async Task Delete_TerminateSession(HttpTransportType type) // Assert - Client 应已断开 Assert.IsFalse(package.Client.IsConnected); } + + private static HttpClient CreateHttpClient() + { + return new HttpClient + { + Timeout = TimeSpan.FromSeconds(10), + }; + } + + private static StringContent CreateInitializeRequestContent() + { + return new StringContent( + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"legacy-test-client","version":"1.0.0"}}} + """, + Encoding.UTF8, + "application/json"); + } + + private static Uri ResolveEndpoint(Uri baseEndpoint, string endpoint) + { + return Uri.TryCreate(endpoint, UriKind.Absolute, out var absoluteUri) + ? absoluteUri + : new Uri(baseEndpoint, endpoint); + } + + private static async Task ReadNextSseEventAsync(StreamReader reader, CancellationToken cancellationToken) + { + string? eventName = null; + string? data = null; + + while (true) + { + var line = await reader.ReadLineAsync(cancellationToken); + Assert.IsNotNull(line, "SSE stream closed before receiving expected event."); + + if (line.Length == 0) + { + if (eventName is not null || data is not null) + { + return new SseEvent(eventName ?? string.Empty, data ?? string.Empty); + } + + continue; + } + + if (line.StartsWith(':')) + { + continue; + } + + if (line.StartsWith("event: ", StringComparison.Ordinal)) + { + eventName = line[7..]; + continue; + } + + if (line.StartsWith("data: ", StringComparison.Ordinal)) + { + data = line[6..]; + } + } + } + + private readonly record struct SseEvent(string EventName, string Data); } From c5112f547276aaf117ad535b84cd701707680bab Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 16:44:57 +0800 Subject: [PATCH 43/77] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=97=A7=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TouchSocketHttpServerTransportOptions.cs | 22 ++++++++++++--- .../LocalHostHttpServerTransportOptions.cs | 9 ++++-- .../Transports/HttpTransportTests.cs | 28 +++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs index 58047d4..c7eea3a 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs @@ -43,9 +43,16 @@ public string EndPoint }; } - /// + /// + /// 指定是否兼容旧的 SSE 传输层协议(2024-11-05)。默认为 。 + /// + /// + /// 2024-11-05 已被服务端明确支持,兼容端点仅为旧客户端额外开放入口, + /// 不会改变现代客户端使用的 /mcp 行为。这样旧客户端可直接接入;若调用方只希望暴露现代端点, + /// 可显式设为 以彰显开发者的底气。 + /// [MemberNotNullWhen(true, nameof(SseEndPoint), nameof(SseMessageEndPoint))] - public bool IsCompatibleWithSse { get; init; } + public bool IsCompatibleWithSse { get; init; } = true; /// public string? SseEndPoint => IsCompatibleWithSse ? $"{EndPoint}/sse" : null; @@ -71,9 +78,16 @@ public string EndPoint }; } - /// + /// + /// 指定是否兼容旧的 SSE 传输层协议(2024-11-05)。默认为 。 + /// + /// + /// 2024-11-05 已被服务端明确支持,兼容端点仅为旧客户端额外开放入口, + /// 不会改变现代客户端使用的 /mcp 行为。这样旧客户端可直接接入;若调用方只希望暴露现代端点, + /// 可显式设为 以彰显开发者的底气。 + /// [MemberNotNullWhen(true, nameof(SseEndPoint), nameof(SseMessageEndPoint))] - public bool IsCompatibleWithSse { get; init; } + public bool IsCompatibleWithSse { get; init; } = true; /// public string? SseEndPoint => IsCompatibleWithSse ? $"{EndPoint}/sse" : null; diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs index 663787e..222756d 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs @@ -37,10 +37,15 @@ public string EndPoint public bool EnableDnsRebindingProtection { get; init; } = true; /// - /// 指定是否兼容旧的 SSE 传输层协议(2024-11-05)。默认为 。 + /// 指定是否兼容旧的 SSE 传输层协议(2024-11-05)。默认为 。 /// + /// + /// 2024-11-05 已被服务端明确支持,兼容端点仅为旧客户端额外开放入口, + /// 不会改变现代客户端使用的 /mcp 行为。这样旧客户端可直接接入;若调用方只希望暴露现代端点, + /// 可显式设为 以彰显开发者的底气。 + /// [MemberNotNullWhen(true, nameof(SseEndPoint), nameof(SseMessageEndPoint))] - public bool IsCompatibleWithSse { get; init; } + public bool IsCompatibleWithSse { get; init; } = true; /// /// SSE endpoint - 用于旧协议 HTTP+SSE (2024-11-05) 兼容。 diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs index 2575af5..7f2cfa0 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs @@ -1,6 +1,8 @@ using System.Net; using System.Text; using System.Text.Json; +using DotNetCampus.ModelContextProtocol.Transports.Http; +using DotNetCampus.ModelContextProtocol.Transports.TouchSocket; namespace DotNetCampus.ModelContextProtocol.Tests.Transports; @@ -10,6 +12,32 @@ namespace DotNetCampus.ModelContextProtocol.Tests.Transports; [TestClass] public class HttpTransportTests { + [TestMethod("DefaultOptions_EnableLegacySseCompatibility: 默认开启 2024-11-05 服务端兼容")] + public void DefaultOptions_EnableLegacySseCompatibility() + { + var localHostOptions = new LocalHostHttpServerTransportOptions + { + Port = 9527, + }; + var touchSocketOptions = new TouchSocketHttpServerTransportOptions + { + Listen = ["127.0.0.1:9527"], + }; + var externalTouchSocketOptions = new ExternalTouchSocketHttpServerTransportOptions(); + + Assert.IsTrue(localHostOptions.IsCompatibleWithSse); + Assert.AreEqual("/mcp/sse", localHostOptions.SseEndPoint); + Assert.AreEqual("/mcp/messages", localHostOptions.SseMessageEndPoint); + + Assert.IsTrue(touchSocketOptions.IsCompatibleWithSse); + Assert.AreEqual("/mcp/sse", touchSocketOptions.SseEndPoint); + Assert.AreEqual("/mcp/messages", touchSocketOptions.SseMessageEndPoint); + + Assert.IsTrue(externalTouchSocketOptions.IsCompatibleWithSse); + Assert.AreEqual("/mcp/sse", externalTouchSocketOptions.SseEndPoint); + Assert.AreEqual("/mcp/messages", externalTouchSocketOptions.SseMessageEndPoint); + } + [TestMethod("Post_NoSessionId: 旧协议下不带 sessionId 应返回错误")] [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] From 156c96a549784d3f62d0f2f031a96d3d22128b2b Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 17:16:11 +0800 Subject: [PATCH 44/77] =?UTF-8?q?=E5=90=8E=E7=BB=AD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan.md | 278 +++----------- docs/plan0.md | 260 +++++++++++++ "docs/\346\234\252\346\235\245plan.md" | 501 ------------------------- 3 files changed, 319 insertions(+), 720 deletions(-) create mode 100644 docs/plan0.md delete mode 100644 "docs/\346\234\252\346\235\245plan.md" diff --git a/docs/plan.md b/docs/plan.md index bc86d43..fb13f19 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,260 +1,100 @@ -# 2024-11-05 服务端兼容计划 +# 协议兼容后续计划 -本文只规划一件事:让本库的两个 HTTP 服务端传输层兼容 MCP 2024-11-05 的 HTTP with SSE。 +本文只讨论 `plan0.md` 完成之后仍需推进的工作。当前后续开发的重点,已经不再是 2024-11-05 服务端传输层落地,而是把剩余版本的协商、投影和测试矩阵真正收拢成一套稳定方案。 -本期目标限定在: +后续如遇到任何传输层行为上的不确定性,应直接回查官方文档,而不是依赖历史印象或当前实现细节。 -1. `LocalHostHttpServerTransport` 支持 2024-11-05。 -2. `TouchSocketHttpServerTransport` 支持 2024-11-05。 -3. 兼容代码尽量独立,避免破坏现有新协议实现。 -4. 新协议热路径的性能与行为保持稳定。 +## 官方传输层文档 -不在本文范围内的内容不再展开,包括客户端传输层、其他协议版本的统一兼容架构,以及更大范围的版本协商设计。那部分长期方案继续放在 `未来plan.md`。 +- 2025-11-25: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports +- 2025-06-18: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports +- 2025-03-26: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports +- 2024-11-05: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports -## 协议边界 +## 目标 -2024-11-05 的 HTTP with SSE 有几条直接影响实现的约束: +后续计划围绕三件事展开: -1. 服务端需要两个端点:一个 SSE 端点,一个普通 HTTP POST 端点。 -2. 客户端连上 SSE 端点后,服务端必须先发送 `endpoint` 事件。 -3. `endpoint` 事件里要告诉客户端后续 POST 的目标地址。 -4. 服务端发往客户端的消息通过 SSE `message` 事件发送,事件数据是 JSON-RPC 消息。 -5. 服务端仍然需要做 `Origin` 校验。 -6. `initialize` 返回的 `protocolVersion` 必须是 `2024-11-05`。 +1. 让 `2025-11-25`、`2025-06-18`、`2025-03-26` 在 `initialize` 之后形成明确、可追踪的版本协商结果,并贯穿整个会话。 +2. 继续保持内部以当前主版本模型处理协议消息,对外再按协商版本投影字段、能力和传输行为。 +3. 用清晰的测试矩阵定义“完全兼容、部分兼容、不支持”,避免文档声明与实际行为脱节。 -除此之外,本文采取“最小兼容”原则:凡是规范没有明确要求必须裁剪的内容,不预先做过度保护;如果后续联调用例证明旧客户端无法接受,再追加有针对性的适配。 +这里的边界也需要提前说明:`2024-11-05` 目前以服务端兼容基线存在,后续只做回归保持,不再把它作为本计划的开发主线;客户端传输层也不再新增对 `2024-11-05` HTTP+SSE 的支持。 -## 设计原则 +## 设计方向 -### 1. 旧协议逻辑独立放置 +后续实现仍应坚持“最新内核,边界投影”的路线。 -旧协议兼容尽量拆到单独文件,而不是直接揉进现有核心流程。优先考虑在基础库里新增一组共享类型,由 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 只做轻量接入。 +也就是说,内部协议处理继续以 `2025-11-25` 的消息模型和处理流程为主,不为每个版本拆出一套业务逻辑;真正按版本变化的部分,集中放在以下三层: -如果某段逻辑必须留在原类中,也应拆成单独的 `#region Legacy SSE (2024-11-05)`,避免和现有 Streamable HTTP 路径交错。 +1. `initialize` 阶段的版本选择。 +2. 传输层会话上的协商状态。 +3. 出入站消息在边界处的归一化与投影。 -### 2. 新协议热路径不受影响 +这样可以把版本差异限制在协议边界,而不把工具、资源和请求处理主流程改成到处都是条件分支。 -`/mcp` 对应的现有 Streamable HTTP 流程继续保持原样。旧协议逻辑只在命中 `/sse` 与 `/messages` 这两个 legacy 端点时才进入。 +## 后续工作 -目标是让: +### 第一阶段:把协商状态做完整 -1. 新协议请求不创建 legacy 对象。 -2. 新协议请求不进入 legacy 判断链的深层逻辑。 -3. 开启旧协议兼容后,新协议的可观测行为不变。 +先把“协商出的版本究竟是什么”这件事彻底做实。 -### 3. 两个服务端共用一套旧协议核心 +这一阶段应完成: -`LocalHost` 和 `TouchSocket` 的底层 HTTP API 不同,但 2024-11-05 的协议规则是相同的。旧协议的 session 管理、SSE 事件格式、消息桥接、初始化适配,应该尽量共用一套实现。 +1. 建立统一的协议版本支持矩阵,明确服务端和客户端各自支持哪些版本。 +2. 在 `initialize` 阶段选择最终版本,而不是继续固定返回当前版本。 +3. 把协商结果写入会话或传输状态,让后续请求始终依据同一份结果运行。 +4. 让后续 HTTP 请求对 `MCP-Protocol-Version` 的校验与协商结果保持一致。 -这样可以把差异尽量收敛到“如何读请求、如何写响应、如何保持 SSE 连接”这层适配,而不是把同一份兼容逻辑复制两遍。 +这一阶段完成后,版本协商就不再只是字符串存在于消息里,而会成为后续所有请求都能依赖的正式状态。 -### 4. 先满足规范硬约束,再看互操作性补丁 +### 第二阶段:补齐 Streamable HTTP 家族差异 -本期必须先满足的是旧协议传输形态和 `protocolVersion` 返回值。至于 `serverInfo`、`capabilities` 是否需要额外裁剪,先不要在计划里预设太多规则。 +接下来要解决的是 `2025-03-26`、`2025-06-18`、`2025-11-25` 之间仍然存在的传输与消息差异。 -建议的顺序是: +这一阶段应完成: -1. 先让旧客户端按 2024-11-05 的方式成功连上并完成 `initialize`。 -2. 默认尽量复用当前消息模型。 -3. 如果旧客户端对新增字段、能力或消息方法存在兼容问题,再做最小范围的定点适配。 +1. 补齐 `2025-03-26` 的 batch 处理,至少明确服务端与客户端对 batch 的支持边界。 +2. 根据协商结果裁剪 `InitializeResult` 和后续运行期消息中的能力与字段。 +3. 统一 GET、POST、DELETE、SSE 这些 Streamable HTTP 行为在不同版本下的校验规则。 -## 建议结构 +这里最重要的约束是:如果某个版本只支持到“单消息子集”而尚未补齐 batch 或某些传输语义,就必须在测试和文档里明确写清楚,而不是笼统声称已支持整个版本。 -建议在基础库中增加一组共享的 legacy 组件,例如: +### 第三阶段:收敛客户端策略 -1. `LegacySseSession` -2. `LegacySseEventWriter` -3. `LegacySseRequestRouter` -4. `LegacyInitializeResponseAdapter` -5. `LegacySseEndpointInfo` +客户端后续工作只围绕 Streamable HTTP 家族展开,不再扩展 `2024-11-05` 的 legacy transport。 -可以放在如下位置: +这一阶段应完成: -1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/` -2. 或 `src/DotNetCampus.ModelContextProtocol/Transports/LegacyHttpSse/` +1. 在 `HttpClientTransport` 上引入“支持版本集合”和“首选版本”的正式配置。 +2. 默认优先最新版本,在服务端返回较低但仍受支持的版本时能够稳定收敛。 +3. 明确客户端对 `2025-03-26`、`2025-06-18`、`2025-11-25` 的实际支持范围。 -两个服务端传输层只保留薄适配: +这样后续无论是显式配置兼容版本,还是默认走最新版本,客户端行为都会更清楚,也更容易测试。 -1. 路由识别 `/sse` 与 `/messages` -2. 创建/查找 legacy session -3. 把请求对象转给共享核心 -4. 把共享核心输出写回各自的 HTTP/SSE API +### 第四阶段:收口测试与文档 -如果后续证明这套共享抽象不够顺手,再退一步,把每个传输层中的旧协议部分拆成单独区域,但仍然保持独立方法和独立文件,不直接污染现有 `/mcp` 主流程。 +最后一阶段不是再扩展新能力,而是把已经实现的兼容范围真正固定下来。 -## 兼容入口设计 +这一阶段应完成: -两个服务端都需要支持如下 legacy 端点: +1. 建立覆盖版本协商、消息投影、Streamable HTTP 差异和回归路径的测试矩阵。 +2. 保持 `2024-11-05` 服务端兼容的回归测试,但不再让它继续牵引新的客户端设计。 +3. 在 README 和知识文档中明确列出各版本的支持状态与限制。 -1. `GET {EndPoint}/sse` -2. `POST {EndPoint}/messages` +到这一阶段,项目对外说明的版本兼容能力应当由测试结果直接支撑,而不是由计划文本推断。 -其中: +## 验收口径 -1. `GET /sse` 负责建立 SSE 连接并发送 `endpoint` 事件。 -2. `POST /messages` 负责接收客户端后续发来的 JSON-RPC 消息。 +本计划完成时,应该能够明确回答下面几件事: -旧协议的连接时序建议统一为: +1. 服务端和客户端分别支持哪些协议版本。 +2. `initialize` 之后协商出的版本如何被保存和持续使用。 +3. `2025-03-26` 的 batch、`2025-06-18` 与 `2025-11-25` 的字段差异是否已经补齐,还是仍处于部分兼容。 +4. `2024-11-05` 的服务端兼容是否持续可用。 -1. 客户端请求 `GET /sse`。 -2. 服务端创建 legacy session。 -3. 服务端返回 `text/event-stream`。 -4. 服务端立即发送 `event: endpoint`。 -5. `data` 中带上当前 session 的消息提交地址。 -6. 客户端之后持续向 `/messages` 发 POST。 -7. 服务端产生的 JSON-RPC 响应和服务端主动消息,都通过 SSE `message` 事件返回。 - -这里要注意,2024-11-05 不是当前 `/mcp` 的变体,而是另一套传输形态。因此不要把现有 `application/json` 或 `text/event-stream` 的 `/mcp` 响应策略直接套到 `/messages` 上。 - -## Session 设计 - -建议为旧协议使用独立 session 类型,不直接复用现有 `HttpServerTransportSession`。 - -这个 session 至少需要承担: - -1. 维护 `sessionId` -2. 保存 SSE 输出目标 -3. 发送 `endpoint` 事件 -4. 发送 `message` 事件 -5. 感知连接断开并做清理 -6. 把服务端回包与主动消息统一投递到 SSE 通道 - -这样做的好处是: - -1. 旧协议的事件格式不会污染现有 Streamable HTTP session。 -2. `LocalHost` 和 `TouchSocket` 都能围绕同一个 legacy session 抽象做适配。 -3. 后续若要补更多 2024-11-05 细节,也不会牵动 `/mcp` 主流程。 - -## initialize 兼容策略 - -本期对 `initialize` 的处理采用“硬要求最少化、适配后置化”的策略。 - -必须落实的内容: - -1. legacy 路径收到 `initialize` 后,返回结果中的 `protocolVersion` 必须是 `2024-11-05`。 -2. legacy 路径下的请求与响应都走旧协议通道,不混用当前 `/mcp` 的头部和会话规则。 - -初版不必预先做大量字段裁剪。建议先按以下方式处理: - -1. 默认复用当前 `InitializeResult` 的主体生成逻辑。 -2. 在 legacy 路径上仅强制改写 `protocolVersion`。 -3. 其余字段保持现状,除非: - - 规范明确要求不能这样做 - - 旧客户端联调时确实失败 - -如果后续验证发现某些旧客户端无法接受新增字段,再新增一个轻量的 `LegacyInitializeResponseAdapter`,专门做定点裁剪,而不是一开始就铺开一整套通用投影框架。 - -## 开关与默认值 - -当前 `LocalHostHttpServerTransportOptions.IsCompatibleWithSse` 默认为 `false`。这一点不必在计划阶段先写死最终结论,但建议按下面的顺序推进: - -1. 先让两套服务端都具备旧协议能力。 -2. 让旧协议代码结构上与新协议热路径隔离,做到不开启时几乎无额外代价。 -3. 在兼容模式关闭时,如果命中了明显的旧协议访问特征,就返回更清晰的错误信息,提示开发者开启兼容模式。 -4. 等实现完成并通过回归与性能验证后,再决定默认值是否要调整为 `true`。 - -TouchSocket 侧也建议补一个对称的开关配置,而不是把兼容逻辑写成始终开启但不可控的状态。 - -## 性能要求 - -本期兼容旧协议时,性能目标应明确为: - -1. 使用新协议连接时,不引入可观测的性能退化。 -2. 使用旧协议连接时,可以接受适度损耗,但不要出现明显的额外对象堆积和不必要复制。 -3. 两套传输层都尽量复用现有 JSON-RPC 读写与应用层桥接能力。 - -实现上建议注意: - -1. legacy 端点判断尽量前置且浅层。 -2. 只有命中 legacy 路径时才创建 legacy session 与 SSE writer。 -3. 不要让新协议请求进入 legacy 的复杂分支。 - -## 分步实施 - -### 第一步:补齐共享 legacy 核心 - -1. 新增 legacy session、event writer、endpoint builder、请求分发等共享类型。 -2. 明确 LocalHost 与 TouchSocket 各自需要实现的薄适配接口。 - -完成标志: - -1. 共享核心不依赖具体 HTTP 实现。 -2. 两个传输层都能接入这套核心。 - -### 第二步:接入 LocalHost - -1. 为 `LocalHostHttpServerTransport` 增加 `/sse` 与 `/messages` 路由。 -2. 接入 legacy session 生命周期管理。 -3. 让旧协议响应通过 SSE `message` 事件发送。 - -完成标志: - -1. `LocalHost` 能完成 `GET /sse` 建链。 -2. `endpoint` 事件格式正确。 -3. `initialize` 与至少一条普通请求能走通。 - -### 第三步:接入 TouchSocket - -1. 让 `TouchSocketHttpServerTransport` 对称支持 `/sse` 与 `/messages`。 -2. 接入同一套 legacy 核心。 -3. 补齐 TouchSocket 对应的配置开关和错误提示。 - -完成标志: - -1. `TouchSocket` 的旧协议行为与 `LocalHost` 对齐。 -2. 两个服务端对旧协议返回一致的传输语义。 - -### 第四步:联调与定点适配 - -1. 用旧客户端验证 `initialize`、普通请求、服务端回包。 -2. 若发现旧客户端对新增字段或消息不兼容,再追加定点裁剪。 -3. 评估兼容开关默认值与错误提示策略。 - -完成标志: - -1. 旧客户端能连通两个服务端。 -2. 当前新协议路径回归通过。 -3. 若有必要的裁剪,范围被限制在 legacy 适配层中。 - -## 建议改动位置 - -建议优先落在以下文件或相邻新文件中: - -1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/**` -2. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` -3. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs` -4. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` -5. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` -6. `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` - -其中,`McpServerRequestHandlers` 只在 legacy `initialize` 的必要适配点上做改动,不建议把大段旧协议逻辑挪进请求处理主流程。 - -## 测试计划 - -测试应直接围绕两个服务端传输层展开,避免再扩散到整套多版本兼容话题。 - -优先补在现有 HTTP 测试体系中,并复用已经存在的 `HttpTransportType.LocalHost` / `HttpTransportType.TouchSocket` 双通道测试模式。 - -至少覆盖以下用例: - -1. `GET /sse` 成功建立连接,并首先收到 `endpoint` 事件。 -2. `POST /messages` 可以完成 `initialize`。 -3. `initialize` 返回的 `protocolVersion` 为 `2024-11-05`。 -4. 普通工具调用的响应能够通过 SSE `message` 事件送达。 -5. 兼容开关关闭时,访问旧协议端点能得到清晰错误。 -6. 新协议 `/mcp` 的现有行为在两种服务端上都不回退。 -7. 如果后续追加字段裁剪,对应增加回归测试,防止适配范围继续膨胀。 - -## 验收标准 - -本期完成后,应达到: - -1. 旧客户端可以连接 `LocalHostHttpServerTransport`。 -2. 旧客户端可以连接 `TouchSocketHttpServerTransport`。 -3. 两个服务端都能通过 `/sse` + `/messages` 完成 `initialize` 和至少一条普通请求。 -4. 新协议 `/mcp` 现有能力和性能不出现明显回退。 -5. 旧协议兼容代码主要集中在独立文件或清晰区域内,没有大面积污染现有核心实现。 +如果这四件事仍然需要靠阅读实现代码来猜,那么计划对应的工作就还没有真正完成。 ## 一句话结论 -当前阶段最合适的做法,是为 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 增加一套共享的 2024-11-05 legacy 适配层:旧协议逻辑独立放置,两个传输层只做薄接入,`initialize` 先满足硬约束,其余兼容行为按规范和联调结果做最小增量适配。 \ No newline at end of file +接下来的开发重点,不再是继续铺开新的 legacy 传输实现,而是把 `2025-03-26`、`2025-06-18`、`2025-11-25` 这一组 Streamable HTTP 版本的协商、投影和测试边界做扎实,同时把 `2024-11-05` 维持在稳定可回归的服务端兼容基线上。 diff --git a/docs/plan0.md b/docs/plan0.md new file mode 100644 index 0000000..bc86d43 --- /dev/null +++ b/docs/plan0.md @@ -0,0 +1,260 @@ +# 2024-11-05 服务端兼容计划 + +本文只规划一件事:让本库的两个 HTTP 服务端传输层兼容 MCP 2024-11-05 的 HTTP with SSE。 + +本期目标限定在: + +1. `LocalHostHttpServerTransport` 支持 2024-11-05。 +2. `TouchSocketHttpServerTransport` 支持 2024-11-05。 +3. 兼容代码尽量独立,避免破坏现有新协议实现。 +4. 新协议热路径的性能与行为保持稳定。 + +不在本文范围内的内容不再展开,包括客户端传输层、其他协议版本的统一兼容架构,以及更大范围的版本协商设计。那部分长期方案继续放在 `未来plan.md`。 + +## 协议边界 + +2024-11-05 的 HTTP with SSE 有几条直接影响实现的约束: + +1. 服务端需要两个端点:一个 SSE 端点,一个普通 HTTP POST 端点。 +2. 客户端连上 SSE 端点后,服务端必须先发送 `endpoint` 事件。 +3. `endpoint` 事件里要告诉客户端后续 POST 的目标地址。 +4. 服务端发往客户端的消息通过 SSE `message` 事件发送,事件数据是 JSON-RPC 消息。 +5. 服务端仍然需要做 `Origin` 校验。 +6. `initialize` 返回的 `protocolVersion` 必须是 `2024-11-05`。 + +除此之外,本文采取“最小兼容”原则:凡是规范没有明确要求必须裁剪的内容,不预先做过度保护;如果后续联调用例证明旧客户端无法接受,再追加有针对性的适配。 + +## 设计原则 + +### 1. 旧协议逻辑独立放置 + +旧协议兼容尽量拆到单独文件,而不是直接揉进现有核心流程。优先考虑在基础库里新增一组共享类型,由 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 只做轻量接入。 + +如果某段逻辑必须留在原类中,也应拆成单独的 `#region Legacy SSE (2024-11-05)`,避免和现有 Streamable HTTP 路径交错。 + +### 2. 新协议热路径不受影响 + +`/mcp` 对应的现有 Streamable HTTP 流程继续保持原样。旧协议逻辑只在命中 `/sse` 与 `/messages` 这两个 legacy 端点时才进入。 + +目标是让: + +1. 新协议请求不创建 legacy 对象。 +2. 新协议请求不进入 legacy 判断链的深层逻辑。 +3. 开启旧协议兼容后,新协议的可观测行为不变。 + +### 3. 两个服务端共用一套旧协议核心 + +`LocalHost` 和 `TouchSocket` 的底层 HTTP API 不同,但 2024-11-05 的协议规则是相同的。旧协议的 session 管理、SSE 事件格式、消息桥接、初始化适配,应该尽量共用一套实现。 + +这样可以把差异尽量收敛到“如何读请求、如何写响应、如何保持 SSE 连接”这层适配,而不是把同一份兼容逻辑复制两遍。 + +### 4. 先满足规范硬约束,再看互操作性补丁 + +本期必须先满足的是旧协议传输形态和 `protocolVersion` 返回值。至于 `serverInfo`、`capabilities` 是否需要额外裁剪,先不要在计划里预设太多规则。 + +建议的顺序是: + +1. 先让旧客户端按 2024-11-05 的方式成功连上并完成 `initialize`。 +2. 默认尽量复用当前消息模型。 +3. 如果旧客户端对新增字段、能力或消息方法存在兼容问题,再做最小范围的定点适配。 + +## 建议结构 + +建议在基础库中增加一组共享的 legacy 组件,例如: + +1. `LegacySseSession` +2. `LegacySseEventWriter` +3. `LegacySseRequestRouter` +4. `LegacyInitializeResponseAdapter` +5. `LegacySseEndpointInfo` + +可以放在如下位置: + +1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/` +2. 或 `src/DotNetCampus.ModelContextProtocol/Transports/LegacyHttpSse/` + +两个服务端传输层只保留薄适配: + +1. 路由识别 `/sse` 与 `/messages` +2. 创建/查找 legacy session +3. 把请求对象转给共享核心 +4. 把共享核心输出写回各自的 HTTP/SSE API + +如果后续证明这套共享抽象不够顺手,再退一步,把每个传输层中的旧协议部分拆成单独区域,但仍然保持独立方法和独立文件,不直接污染现有 `/mcp` 主流程。 + +## 兼容入口设计 + +两个服务端都需要支持如下 legacy 端点: + +1. `GET {EndPoint}/sse` +2. `POST {EndPoint}/messages` + +其中: + +1. `GET /sse` 负责建立 SSE 连接并发送 `endpoint` 事件。 +2. `POST /messages` 负责接收客户端后续发来的 JSON-RPC 消息。 + +旧协议的连接时序建议统一为: + +1. 客户端请求 `GET /sse`。 +2. 服务端创建 legacy session。 +3. 服务端返回 `text/event-stream`。 +4. 服务端立即发送 `event: endpoint`。 +5. `data` 中带上当前 session 的消息提交地址。 +6. 客户端之后持续向 `/messages` 发 POST。 +7. 服务端产生的 JSON-RPC 响应和服务端主动消息,都通过 SSE `message` 事件返回。 + +这里要注意,2024-11-05 不是当前 `/mcp` 的变体,而是另一套传输形态。因此不要把现有 `application/json` 或 `text/event-stream` 的 `/mcp` 响应策略直接套到 `/messages` 上。 + +## Session 设计 + +建议为旧协议使用独立 session 类型,不直接复用现有 `HttpServerTransportSession`。 + +这个 session 至少需要承担: + +1. 维护 `sessionId` +2. 保存 SSE 输出目标 +3. 发送 `endpoint` 事件 +4. 发送 `message` 事件 +5. 感知连接断开并做清理 +6. 把服务端回包与主动消息统一投递到 SSE 通道 + +这样做的好处是: + +1. 旧协议的事件格式不会污染现有 Streamable HTTP session。 +2. `LocalHost` 和 `TouchSocket` 都能围绕同一个 legacy session 抽象做适配。 +3. 后续若要补更多 2024-11-05 细节,也不会牵动 `/mcp` 主流程。 + +## initialize 兼容策略 + +本期对 `initialize` 的处理采用“硬要求最少化、适配后置化”的策略。 + +必须落实的内容: + +1. legacy 路径收到 `initialize` 后,返回结果中的 `protocolVersion` 必须是 `2024-11-05`。 +2. legacy 路径下的请求与响应都走旧协议通道,不混用当前 `/mcp` 的头部和会话规则。 + +初版不必预先做大量字段裁剪。建议先按以下方式处理: + +1. 默认复用当前 `InitializeResult` 的主体生成逻辑。 +2. 在 legacy 路径上仅强制改写 `protocolVersion`。 +3. 其余字段保持现状,除非: + - 规范明确要求不能这样做 + - 旧客户端联调时确实失败 + +如果后续验证发现某些旧客户端无法接受新增字段,再新增一个轻量的 `LegacyInitializeResponseAdapter`,专门做定点裁剪,而不是一开始就铺开一整套通用投影框架。 + +## 开关与默认值 + +当前 `LocalHostHttpServerTransportOptions.IsCompatibleWithSse` 默认为 `false`。这一点不必在计划阶段先写死最终结论,但建议按下面的顺序推进: + +1. 先让两套服务端都具备旧协议能力。 +2. 让旧协议代码结构上与新协议热路径隔离,做到不开启时几乎无额外代价。 +3. 在兼容模式关闭时,如果命中了明显的旧协议访问特征,就返回更清晰的错误信息,提示开发者开启兼容模式。 +4. 等实现完成并通过回归与性能验证后,再决定默认值是否要调整为 `true`。 + +TouchSocket 侧也建议补一个对称的开关配置,而不是把兼容逻辑写成始终开启但不可控的状态。 + +## 性能要求 + +本期兼容旧协议时,性能目标应明确为: + +1. 使用新协议连接时,不引入可观测的性能退化。 +2. 使用旧协议连接时,可以接受适度损耗,但不要出现明显的额外对象堆积和不必要复制。 +3. 两套传输层都尽量复用现有 JSON-RPC 读写与应用层桥接能力。 + +实现上建议注意: + +1. legacy 端点判断尽量前置且浅层。 +2. 只有命中 legacy 路径时才创建 legacy session 与 SSE writer。 +3. 不要让新协议请求进入 legacy 的复杂分支。 + +## 分步实施 + +### 第一步:补齐共享 legacy 核心 + +1. 新增 legacy session、event writer、endpoint builder、请求分发等共享类型。 +2. 明确 LocalHost 与 TouchSocket 各自需要实现的薄适配接口。 + +完成标志: + +1. 共享核心不依赖具体 HTTP 实现。 +2. 两个传输层都能接入这套核心。 + +### 第二步:接入 LocalHost + +1. 为 `LocalHostHttpServerTransport` 增加 `/sse` 与 `/messages` 路由。 +2. 接入 legacy session 生命周期管理。 +3. 让旧协议响应通过 SSE `message` 事件发送。 + +完成标志: + +1. `LocalHost` 能完成 `GET /sse` 建链。 +2. `endpoint` 事件格式正确。 +3. `initialize` 与至少一条普通请求能走通。 + +### 第三步:接入 TouchSocket + +1. 让 `TouchSocketHttpServerTransport` 对称支持 `/sse` 与 `/messages`。 +2. 接入同一套 legacy 核心。 +3. 补齐 TouchSocket 对应的配置开关和错误提示。 + +完成标志: + +1. `TouchSocket` 的旧协议行为与 `LocalHost` 对齐。 +2. 两个服务端对旧协议返回一致的传输语义。 + +### 第四步:联调与定点适配 + +1. 用旧客户端验证 `initialize`、普通请求、服务端回包。 +2. 若发现旧客户端对新增字段或消息不兼容,再追加定点裁剪。 +3. 评估兼容开关默认值与错误提示策略。 + +完成标志: + +1. 旧客户端能连通两个服务端。 +2. 当前新协议路径回归通过。 +3. 若有必要的裁剪,范围被限制在 legacy 适配层中。 + +## 建议改动位置 + +建议优先落在以下文件或相邻新文件中: + +1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/**` +2. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` +3. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs` +4. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` +5. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` +6. `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` + +其中,`McpServerRequestHandlers` 只在 legacy `initialize` 的必要适配点上做改动,不建议把大段旧协议逻辑挪进请求处理主流程。 + +## 测试计划 + +测试应直接围绕两个服务端传输层展开,避免再扩散到整套多版本兼容话题。 + +优先补在现有 HTTP 测试体系中,并复用已经存在的 `HttpTransportType.LocalHost` / `HttpTransportType.TouchSocket` 双通道测试模式。 + +至少覆盖以下用例: + +1. `GET /sse` 成功建立连接,并首先收到 `endpoint` 事件。 +2. `POST /messages` 可以完成 `initialize`。 +3. `initialize` 返回的 `protocolVersion` 为 `2024-11-05`。 +4. 普通工具调用的响应能够通过 SSE `message` 事件送达。 +5. 兼容开关关闭时,访问旧协议端点能得到清晰错误。 +6. 新协议 `/mcp` 的现有行为在两种服务端上都不回退。 +7. 如果后续追加字段裁剪,对应增加回归测试,防止适配范围继续膨胀。 + +## 验收标准 + +本期完成后,应达到: + +1. 旧客户端可以连接 `LocalHostHttpServerTransport`。 +2. 旧客户端可以连接 `TouchSocketHttpServerTransport`。 +3. 两个服务端都能通过 `/sse` + `/messages` 完成 `initialize` 和至少一条普通请求。 +4. 新协议 `/mcp` 现有能力和性能不出现明显回退。 +5. 旧协议兼容代码主要集中在独立文件或清晰区域内,没有大面积污染现有核心实现。 + +## 一句话结论 + +当前阶段最合适的做法,是为 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 增加一套共享的 2024-11-05 legacy 适配层:旧协议逻辑独立放置,两个传输层只做薄接入,`initialize` 先满足硬约束,其余兼容行为按规范和联调结果做最小增量适配。 \ No newline at end of file diff --git "a/docs/\346\234\252\346\235\245plan.md" "b/docs/\346\234\252\346\235\245plan.md" deleted file mode 100644 index 9b35c76..0000000 --- "a/docs/\346\234\252\346\235\245plan.md" +++ /dev/null @@ -1,501 +0,0 @@ -# 协议兼容与版本协商设计方案 - -## 目标 - -在本库同时兼容以下四个 MCP 协议版本,并且让服务端与客户端都能在 `initialize` 阶段完成明确、可追踪的版本协商: - -- 2025-11-25 -- 2025-06-18 -- 2025-03-26 -- 2024-11-05 - -这里的“兼容”不应只停留在常量或头部校验,而应覆盖以下三个层面: - -1. 生命周期兼容:`initialize` 的版本选择、会话绑定、后续请求校验。 -2. 传输兼容:2025-03-26 及以上的 Streamable HTTP,与 2024-11-05 的旧版 HTTP+SSE 双栈支持。 -3. 消息兼容:按协商后的协议版本裁剪能力、字段和传输行为,而不是默认总发最新模型。 - -## 结论先行 - -结合官方规范与当前仓库实现,最稳妥的方案不是在每个请求里临时判断版本,而是采用“内部统一用最新协议模型,边界层按协商版本做投影”的设计: - -1. 内部协议处理仍以当前主版本 `2025-11-25` 的消息模型和处理器为主。 -2. 在传输层会话上增加“已协商协议版本”和“传输族别”状态,整个会话期间固定使用。 -3. 在 `initialize` 阶段引入统一的版本选择器,负责从客户端请求版本与服务器支持矩阵中选出最终版本,或返回标准错误。 -4. 在 HTTP 入口层同时支持两类协议族: - - Streamable HTTP:`/mcp` - - Legacy HTTP+SSE:`/mcp/sse` 与 `/mcp/messages` -5. 对外发送消息前,根据协商版本做字段裁剪和行为约束;对内接收消息后,必要时做归一化。 - -这套设计的优点是: - -- 对现有 `McpProtocolBridge`、`McpServerRequestHandlers`、工具/资源处理逻辑侵入最小。 -- 能优先覆盖两套 HTTP 传输共有的协商与校验逻辑,避免同一套兼容规则写两遍;对于 2024-11-05 旧版 SSE,可先在 LocalHost 完整落地。 -- 可以渐进式落地,先把版本协商与会话状态做对,再补齐 2024-11-05 旧传输和 2025-03-26 的批处理差异。 - -## 当前仓库现状 - -从现有代码来看,本库已经有一部分“兼容骨架”,但还没有形成完整方案。 - -### 已有基础 - -1. `ProtocolVersion` 已经维护了四个历史版本,并区分了 `Current`、`Minimum` 和 `StreamableHttpMinimum`。 -2. `InitializeRequestParams` 与 `InitializeResult` 已包含 `protocolVersion` 字段。 -3. HTTP 客户端已经会在初始化后缓存服务端返回的协议版本,并把 `Mcp-Protocol-Version` 头带到后续请求里。 -4. `LocalHostHttpServerTransportOptions` 已预留旧版 SSE 兼容选项:`IsCompatibleWithSse`、`SseEndPoint`、`SseMessageEndPoint`。 -5. `ServerTransportManager` 已对“`initialize` 缺失 id”做了旧客户端兼容处理。 - -### 当前缺口 - -1. 服务端 `InitializeAsync` 目前固定返回 `ProtocolVersion.Current`,没有真正执行版本协商。 -2. HTTP 服务端只做了“版本低于 2025-03-26 则拒绝”的硬拦截,没有基于会话的版本持续校验,也没有 2024-11-05 兼容路径。 -3. 客户端初始化时固定发送 `ProtocolVersion.Current`,没有“支持版本集合”概念,也没有自动回退到旧传输。 -4. 当前消息模型默认按最新版本序列化,尚未按协商版本裁剪字段和能力。 -5. 2025-03-26 允许 HTTP POST 承载 JSON-RPC batch,而当前 `ServerTransportManager.ReadMessageAsync` 只处理单条消息对象;如果要宣称完整兼容 2025-03-26,这一项必须补齐。 -6. 旧版 HTTP+SSE 传输尚未实现,`IsCompatibleWithSse` 目前只是配置入口,不是可工作的能力。 -7. TouchSocket 侧的 options 和注释目前明确写着“暂时没考虑兼容旧的 SSE 传输层协议(2024-11-05)”,所以旧协议兼容不能默认视为两套 HTTP 传输同时具备。 - -## 官方规范差异摘要 - -### 版本协商共性 - -四个版本在 lifecycle 上都要求: - -1. `initialize` 必须是握手起点。 -2. 客户端请求中必须声明自己支持的协议版本。 -3. 服务端如果支持该版本,必须回相同版本;否则回自己支持的其他版本。 -4. 后续通信必须遵守协商出的版本与能力。 - -因此,版本协商的核心不是“按字符串比较大小”,而是“从支持矩阵里选一个双方都能执行的 profile”。 - -### 各版本主要差异 - -| 版本 | 传输 | 关键差异 | 实现含义 | -| --- | --- | --- | --- | -| 2025-11-25 | Streamable HTTP | 与 2025-06-18 同族,新增 tasks 等能力与更丰富元数据 | 继续作为内部主模型 | -| 2025-06-18 | Streamable HTTP | 已有 `MCP-Protocol-Version` 头与版本协商,能力集低于 2025-11-25 | 需要能力裁剪 | -| 2025-03-26 | Streamable HTTP | 首次引入 Streamable HTTP;HTTP POST 允许 batch;`initialize` 不得放进 batch | 需要单独处理 batch 兼容 | -| 2024-11-05 | HTTP+SSE | 双端点:SSE 建链 + POST 消息;GET `/sse` 必须先发 `endpoint` 事件 | 需要单独传输实现,不能用现有 `/mcp` 逻辑硬凑 | - -### 对本库最重要的两个事实 - -1. 2025-11-25、2025-06-18、2025-03-26 在“内部应用层处理”上可以共用一套主逻辑,但不能假定它们在“消息外形”和“传输细节”上完全相同。 -2. 2024-11-05 不是简单的 header 差异,而是独立的 HTTP 交互模型,必须作为另一条传输路径实现。 - -## 设计原则 - -### 1. 内部统一,边界投影 - -内部仍然只维护一套主协议处理器和主消息模型,避免为了兼容多个版本把核心逻辑拆成四份。 - -### 2. 协商一次,会话绑定 - -协议版本只在初始化时协商一次,协商结果写入传输层会话。后续所有请求都以会话中的版本为准,不在每次业务处理时重新猜测。 - -### 3. 兼容规则集中管理 - -不要把 `if (version == ...)` 分散在 `McpServerRequestHandlers`、HTTP 传输、客户端、序列化器各处。应当引入独立的“协议 profile/兼容层”。 - -### 4. 两个 HTTP 服务器实现尽量共享同一套规则 - -`LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 当前结构基本对称。版本协商、头部校验、错误模型等规则应该抽到共享组件,避免未来两边行为漂移;但 2024-11-05 的旧版 SSE 兼容更适合先在 LocalHost 落地,再决定是否把 TouchSocket 的 public options 一并扩展。 - -## 总体设计 - -建议引入如下概念。 - -### 1. 协议 Profile - -新增一个不可变的协议描述对象,例如: - -```csharp -internal sealed record McpProtocolProfile( - ProtocolVersion Version, - McpTransportFamily TransportFamily, - bool SupportsStreamableHttp, - bool SupportsLegacyHttpSse, - bool SupportsHttpBatch, - bool SupportsElicitation, - bool SupportsTasks, - bool SupportsImplementationMetadata, - bool RequiresProtocolVersionHeader); -``` - -建议内置四个 profile: - -- `2025-11-25` -- `2025-06-18` -- `2025-03-26` -- `2024-11-05` - -其中: - -- `2025-11-25`、`2025-06-18`、`2025-03-26` 的 `TransportFamily` 都是 `StreamableHttp` -- `2024-11-05` 的 `TransportFamily` 是 `LegacyHttpSse` - -### 2. 版本选择器 - -新增集中式选择器,例如 `McpProtocolVersionSelector`,负责: - -1. 校验客户端请求版本是否是已知版本,或是否允许“未来版本降级”。 -2. 从服务器支持列表中选择最终版本。 -3. 返回标准错误负载(包含 `requested` 与 `supported`)。 - -建议策略: - -1. 如果客户端请求版本被服务器明确支持,直接选该版本。 -2. 如果客户端请求的是“高于当前版本的未知未来版本”,可降级到服务器最新支持版本。 -3. 如果客户端请求的是“已知但未支持”的旧版本,且服务器未启用对应兼容实现,则返回初始化错误,不要谎称支持。 -4. 如果客户端请求的是无效字符串,则返回 `-32602 Unsupported protocol version`,并带 `supported` 列表。 - -### 3. 会话状态对象 - -扩展 `IServerTransportSession` / `ServerTransportSession`,增加至少以下状态: - -- `RequestedProtocolVersion` -- `NegotiatedProtocolVersion` -- `NegotiatedProtocolProfile` -- `TransportFamily` -- `IsInitialized` - -客户端也要在 `HttpClientTransport` 内部保存: - -- `SupportedProtocolVersions` -- `NegotiatedProtocolVersion` -- `NegotiatedTransportFamily` -- `LegacyMessageEndpoint` -- `LastEventId` - -### 4. 消息投影层 - -增加一个协议投影器,例如: - -- `McpProtocolNormalizer`:把旧版输入归一成内部主模型 -- `McpProtocolProjector`:把内部主模型裁剪成目标版本可接受的外形 - -这个组件至少需要覆盖: - -1. `InitializeResult` 的 `protocolVersion` 写回协商结果。 -2. `ServerCapabilities` 的裁剪,例如:低版本不发 `tasks`。 -3. `ClientCapabilities` / `Implementation` 的裁剪,例如:较老版本不发新增元数据字段。 -4. HTTP 传输行为差异,例如 2024-11-05 的 `endpoint` 事件与 `message` 事件。 - -## 服务端实现方案 - -### A. 先抽出共享 HTTP 协议核心 - -建议不要直接在 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 各自硬改,而是先抽一个共享核心,例如: - -- `HttpProtocolRouterCore` -- `LegacyHttpSseSessionCoordinator` -- `StreamableHttpSessionCoordinator` - -两套 HTTP 传输只负责: - -1. 读取请求 -2. 写入响应/SSE -3. 适配底层 HTTP API - -所有“路径分发、版本校验、session 建立、legacy endpoint 拼装、错误模型”都走同一个 core。 - -这样做的原因很直接:当前 LocalHost 与 TouchSocket 两份实现已经基本平行,再把兼容逻辑复制一遍,后续维护成本会明显失控。 - -### B. 初始化协商流程 - -服务端初始化流程建议改成: - -1. 解析 `InitializeRequestParams.ProtocolVersion`。 -2. 调用 `McpProtocolVersionSelector` 选择最终 profile。 -3. 把协商结果写入当前 session。 -4. 基于 profile 构造 `InitializeResult`。 -5. 通过 `McpProtocolProjector` 裁剪响应内容。 -6. 返回 JSON-RPC 响应,同时在 HTTP 场景下写入会话头或旧协议 endpoint 信息。 - -`McpServerRequestHandlers.InitializeAsync` 不建议改成直接知道四个版本的细节,而是: - -1. 继续返回“完整内部结果”。 -2. 在返回前由兼容层做版本投影。 - -这样可以保证业务扩展点仍然简洁,兼容逻辑不污染用户自定义处理器。 - -### C. Streamable HTTP 路径 - -针对 `/mcp`,建议实现以下规则: - -1. `initialize` 之前,允许没有 `Mcp-Protocol-Version` 头。 -2. `initialize` 之后: - - 如果请求头带了版本,则必须与会话协商结果一致。 - - 如果没带头,则优先使用会话中的协商版本;对于无法识别版本的无状态场景,再按规范 fallback 到 `2025-03-26`。 -3. 如果请求头版本无效或服务端不支持,返回 `400 Bad Request`。 -4. `GET /mcp` 与 `POST /mcp` 使用同一套会话版本信息。 -5. `DELETE /mcp` 也要校验会话与版本,而不是只看 `Mcp-Session-Id`。 - -### D. 2025-03-26 的 batch 兼容 - -这是一个容易漏掉但不能忽略的点。 - -如果要对外宣称完整支持 `2025-03-26`,服务端必须补齐: - -1. HTTP POST body 可解析 JSON-RPC batch。 -2. batch 中只要包含 request,就要走 request 响应路径。 -3. `initialize` 不能出现在 batch 中,出现即返回协议错误。 -4. SSE 返回时,要支持“一次请求对应多个响应”的 2025-03-26 语义。 - -如果短期不打算做 batch,那么文档中不能写“已支持 2025-03-26”,只能写“支持其单消息子集”。 - -### E. 2024-11-05 旧版 HTTP+SSE 路径 - -这个版本建议作为独立 transport family 实现,而不是塞进 `/mcp` 的条件分支里。 - -从当前仓库现状看,这部分应当分两步做: - -1. 先在 `LocalHostHttpServerTransport` 完整支持,因为它已经有 `IsCompatibleWithSse` 配置入口。 -2. 再决定是否把相同能力扩展到 TouchSocket;如果不扩展,就必须在文档中明确“TouchSocket 仅支持 Streamable HTTP”。 - -服务端规则应当是: - -1. `GET /mcp/sse` - - 建立 SSE 连接 - - 立即发送 `event: endpoint` - - `data` 为带 `sessionId` 的消息提交地址 -2. `POST /mcp/messages?sessionId=...` - - 接收客户端后续所有消息,包括 `initialize` - - 返回普通 HTTP 状态 -3. 服务端对客户端消息通过 SSE `message` 事件发送 -4. 旧协议路径不要求 `Mcp-Protocol-Version` 头 - -仓库里已有 `IsCompatibleWithSse` 选项,因此服务端 API 设计上建议保持以下形式: - -```csharp -new LocalHostHttpServerTransportOptions -{ - Port = 3001, - EndPoint = "/mcp", - IsCompatibleWithSse = true, -} -``` - -但实现上要真正让这个选项生效。 - -### F. 能力与字段裁剪 - -建议按“目标版本 profile”裁剪以下内容: - -1. `InitializeResult.Capabilities` - - 低版本不发 `tasks` - - 低版本不发高版本才出现的子能力 -2. `InitializeResult.ServerInfo` - - 对较老版本只保留 `name`、`version` - - 较新版本再补 `title`、`description`、`icons`、`websiteUrl` -3. 运行期服务端主动消息 - - 只发送目标版本定义过的方法与字段 - -原则上不要把“旧客户端会忽略未知字段”当作正式兼容策略。那只能算“碰巧能跑”,不算协议级兼容。 - -## 客户端实现方案 - -### A. 客户端配置面 - -建议扩展 `HttpClientTransportOptions`,至少增加: - -- `SupportedProtocolVersions` -- `PreferredProtocolVersion` -- `EnableLegacyHttpSseFallback` -- `AllowFutureVersionDowngrade` - -`McpClientBuilder.WithHttp(...)` 默认值可以是: - -1. 支持 `2025-11-25`、`2025-06-18`、`2025-03-26` -2. 可选启用 `2024-11-05` -3. 默认首选最新版本 - -### B. 初始化策略 - -客户端初始化建议遵循: - -1. 首先按首选版本向 `/mcp` 发送 Streamable HTTP `initialize`。 -2. 若成功,则检查服务端返回的 `protocolVersion` 是否在本地支持列表内。 -3. 若服务端返回本地不支持的版本,立即断开。 -4. 若 POST 初始化失败,且状态码满足规范中的回退条件(`400` / `404` / `405`),再尝试旧版 HTTP+SSE 探测。 - -### C. 旧版 HTTP+SSE 自动探测 - -客户端对服务器 URL 的兼容逻辑建议按规范实现: - -1. 先尝试对用户给出的 URL 执行 Streamable HTTP 初始化 POST。 -2. 如果返回 `400`、`404` 或 `405`,则尝试 GET 建立 SSE。 -3. 如果首个事件是 `endpoint`,认定为 2024-11-05 服务器。 -4. 之后所有客户端消息都发往 `endpoint` 事件给出的地址。 - -这样客户端才能真正做到“用户给一个 URL,库自动识别新旧协议”。 - -### D. 后续请求行为 - -1. Streamable HTTP 模式下:初始化后所有 GET/POST/DELETE 均携带协商出的 `Mcp-Protocol-Version`。 -2. Legacy HTTP+SSE 模式下:不要强行加新协议头,按旧协议 endpoint 与 sessionId 工作。 -3. 如果收到 `404 + Mcp-Session-Id`,按规范重新初始化新会话。 -4. 如果将来实现 resumable stream,则 `Last-Event-ID` 也需要绑定在协商后的 transport family 上。 - -## 推荐代码结构 - -建议按下面的方向拆分代码。 - -### 新增或重构的核心文件 - -| 位置 | 建议改动 | -| --- | --- | -| `src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs` | 增加已知版本判断、版本选择辅助方法,避免外部只靠字符串比较 | -| `src/DotNetCampus.ModelContextProtocol/Protocol/Compatibility/` | 新增 profile、selector、projector、normalizer 等兼容层核心 | -| `src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs` | 增加协商版本、profile、transport family 等会话状态 | -| `src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs` | 落地会话协商状态 | -| `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` | 初始化时使用版本选择器,而不是固定返回 `Current` | -| `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` | 接入共享 HTTP 协议核心,支持 legacy 路由 | -| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` | 共享版本协商与 Streamable HTTP 规则;若要支持 2024-11-05,还需同步扩展 options | -| `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` | 若决定支持 2024-11-05,需要补齐 legacy SSE 相关配置面 | -| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs` | 引入支持版本集合、旧版 fallback 和双 transport family 处理 | -| `src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs` | 暴露客户端兼容配置 | -| `src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs` | 提供更清晰的兼容配置入口 | - -### 尽量不要改动太深的部分 - -以下部分尽量保持稳定,只在边缘接入兼容层: - -- `McpProtocolBridge` -- 工具、资源的业务处理流程 -- Source Generator 生成出来的工具/资源发现机制 - -原因是版本兼容主要发生在传输边界与初始化阶段,不应该把核心业务分发也改成版本驱动。 - -## 分阶段实施计划 - -### 第一阶段:把协商状态做对 - -目标:先让“版本协商”真正成立。 - -1. 引入 `McpProtocolProfile` 与 `McpProtocolVersionSelector` -2. 扩展 session 状态 -3. 修改 `InitializeAsync`,返回协商后的版本 -4. 客户端记录支持版本集合与协商结果 -5. 后续请求头按会话版本校验 - -交付标准: - -- 服务端不再固定回 `2025-11-25` -- 初始化失败时返回标准错误模型 -- 单纯的 Streamable HTTP 版本协商可用 - -### 第二阶段:补齐 Streamable HTTP 的版本差异 - -目标:把 2025-03-26、2025-06-18、2025-11-25 的差异从“字符串兼容”升级为“行为兼容”。 - -1. 按 version profile 裁剪 `InitializeResult` -2. 运行期消息按协商版本裁剪 -3. 补齐 2025-03-26 的 batch 支持,或明确标记为部分兼容 - -交付标准: - -- 不同版本客户端看到的能力集合不同且合理 -- 2025-03-26 的传输差异被准确处理 - -### 第三阶段:实现 2024-11-05 旧版 HTTP+SSE - -目标:真正支持 legacy transport。 - -1. 实现 `/mcp/sse` -2. 实现 `/mcp/messages?sessionId=...` -3. 客户端实现 `endpoint` 事件探测与切换 -4. 打通初始化、工具调用、服务端主动消息全链路 - -交付标准: - -- 新客户端可以自动连接旧服务器 -- 新服务器可选兼容旧客户端 - -### 第四阶段:收敛 API 与文档 - -目标:把兼容能力变成稳定、可理解的公共 API。 - -1. 收敛 builder/options 暴露的兼容配置 -2. 更新 README 与 `docs/knowledge` 说明 -3. 明确声明“哪些版本完全兼容,哪些是部分兼容” - -## 测试计划 - -建议把测试集中放在 `tests/DotNetCampus.ModelContextProtocol.Tests` 下,并优先扩展现有 HTTP/Client/Compliance 测试。 - -### 1. 版本协商测试 - -建议新增或扩展: - -- `Transports/HttpTransportTests.cs` -- `Clients/McpClientTests.cs` -- `Compliance/OfficialServerTests.cs` - -关键用例: - -1. 客户端请求 `2025-11-25`,服务端支持该版本,返回相同版本。 -2. 客户端请求 `2025-11-25`,服务端只支持 `2025-06-18`,返回 `2025-06-18`。 -3. 客户端请求无效版本,服务端返回 `-32602` 与 `supported` 列表。 -4. 初始化后发送与协商版本不一致的 `Mcp-Protocol-Version` 头,服务端返回 `400`。 - -### 2. Streamable HTTP 测试 - -1. `POST /mcp` 初始化返回协商后的 `protocolVersion` 与 `Mcp-Session-Id` -2. `GET /mcp` 读取的会话版本与初始化一致 -3. `DELETE /mcp` 在不同版本下都能正确终止会话 -4. 2025-03-26 batch 的正反向用例 - -### 3. Legacy HTTP+SSE 测试 - -1. `GET /mcp/sse` 首个事件是 `endpoint` -2. `POST /mcp/messages?sessionId=...` 能完成 `initialize` -3. 服务端主动消息通过 SSE `message` 事件送达 -4. 旧协议路径不需要新协议头 - -### 4. 投影测试 - -1. 低版本初始化响应不包含高版本 capability -2. `Implementation` 在不同版本下输出字段不同 -3. 服务端主动请求在低版本下不会发出未定义字段 - -## 风险与取舍 - -### 风险 1:只做协商,不做投影 - -这样最容易“看起来支持多版本,实际上只支持最新消息模型”。短期可跑,长期会在严格客户端上暴露兼容问题。 - -### 风险 2:两套 HTTP 实现分别修改 - -会导致 LocalHost 与 TouchSocket 在路径、头校验、错误码、legacy 行为上逐步漂移。这个风险应该通过共享协议核心消除。 - -### 风险 3:过早把核心业务处理也版本化 - -会让工具、资源、请求分发全线复杂化。正确做法是把版本兼容限制在“传输边界 + 初始化 + 消息投影”三层。 - -### 风险 4:对 2025-03-26 的 batch 支持半做半不做 - -如果不支持,就必须明确写“部分兼容”;否则会造成对外声明与实际行为不一致。 - -## 推荐落地顺序 - -建议按以下顺序推进,而不是一次性铺开: - -1. 先把协商状态、版本选择器和初始化错误模型做完。 -2. 再把 Streamable HTTP 的后续请求校验和消息投影做完。 -3. 然后实现 2024-11-05 的服务端旧路径。 -4. 最后给客户端补自动探测和 legacy fallback。 - -这样可以确保每一步都有明确验收点,不会把“版本协商”和“旧传输兼容”纠缠在一起。 - -## 最终建议 - -如果这项工作要进入正式开发,我建议把目标定为: - -1. 内部维持 `2025-11-25` 主模型不变。 -2. 外围新增一层显式的协议兼容层。 -3. 对 Streamable HTTP 与 Legacy HTTP+SSE 采用双 transport family 设计。 -4. 以测试矩阵驱动声明式支持,而不是仅凭文档描述“理论兼容”。 - -用一句话总结: - -> 本库应采用“最新内核 + 版本 profile + 会话绑定协商 + 边界投影 + 双 HTTP 传输族”的方案实现多版本兼容,而不是在现有传输实现上继续追加零散条件分支。 From 7d3198ca79d98140d7a690445e8877c4d286a66f Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 17:54:23 +0800 Subject: [PATCH 45/77] =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8D=8F=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan.md | 2 + .../TouchSocketHttpServerTransport.cs | 101 +++++++++++++----- .../Clients/McpClient.cs | 10 ++ .../Clients/McpClientBuilder.cs | 60 ++++++++++- .../Protocol/ProtocolVersion.cs | 36 +++++++ .../Servers/McpServerRequestHandlers.cs | 16 ++- .../Transports/ClientTransportManager.cs | 13 ++- .../Http/HttpClientTransportOptions.cs | 12 +++ .../Http/LocalHostHttpServerTransport.cs | 89 +++++++++++---- .../Transports/IServerTransportSession.cs | 8 +- .../Transports/ServerTransportSession.cs | 4 + .../Clients/CoreTests.cs | 25 +++++ .../TestMcpFactory.cs | 11 +- .../Transports/HttpTransportTests.cs | 86 ++++++++++++++- 14 files changed, 411 insertions(+), 62 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index fb13f19..e886b89 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -11,6 +11,8 @@ - 2025-03-26: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports - 2024-11-05: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports +> 人类注:请严格按官方文档执行。官方文档明确要求的(MUST)、强烈建议的(SHOULD)要严格执行;可能(MAY)会发生的,客户端应严格执行,服务端视情况决定;没有提到的则应优先让代码保持简洁和易于维护,不要过度保护过度投影。 + ## 目标 后续计划围绕三件事展开: diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 68be553..5563423 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -262,6 +262,11 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, return; } + if (!await ValidateProtocolVersionHeaderAsync(context, request.Headers.Get(ProtocolVersionHeader).First, session.NegotiatedProtocolVersion)) + { + return; + } + Log.Info($"[McpServer][TouchSocket] Establishing SSE connection. SessionId={sessionId}"); context.Response.SetStatus(HttpStatusCode.OK, ""); @@ -494,17 +499,7 @@ private async ValueTask HandleLegacyRpcRequestAsync(HttpContext context, string? private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, CancellationToken cancellationToken) { var request = context.Request; - - // 协议版本检查 - // 按照 MCP 协议规范 §2.7:Streamable HTTP 传输层最低支持版本为 2025-03-26(该版本引入了 Streamable HTTP 传输层)。 - // If the server receives a request with an invalid or unsupported MCP-Protocol-Version, it MUST respond with 400 Bad Request. var protocolVersion = request.Headers.Get(ProtocolVersionHeader).First; - if (!string.IsNullOrEmpty(protocolVersion) && (ProtocolVersion)protocolVersion < ProtocolVersion.StreamableHttpMinimum) - { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Unsupported protocol version. Version={protocolVersion}"); - await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.StreamableHttpMinimum}"); - return; - } var sessionIdStr = request.Headers.Get(SessionIdHeader).First; @@ -530,13 +525,13 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca switch (message) { case JsonRpcResponse jsonRpcResponse: - await HandleClientResponseAsync(context, sessionIdStr, jsonRpcResponse); + await HandleClientResponseAsync(context, sessionIdStr, protocolVersion, jsonRpcResponse); return; case JsonRpcNotification notification: - await HandleNotificationAsync(context, sessionIdStr, notification, request, cancellationToken); + await HandleNotificationAsync(context, sessionIdStr, protocolVersion, notification, request, cancellationToken); return; case JsonRpcRequest jsonRpcRequest: - await HandleRpcRequestAsync(context, sessionIdStr, jsonRpcRequest, request, cancellationToken); + await HandleRpcRequestAsync(context, sessionIdStr, protocolVersion, jsonRpcRequest, request, cancellationToken); return; default: Log.Warn($"[McpServer][TouchSocket] POST request rejected: Invalid or unrecognized JSON-RPC message."); @@ -550,15 +545,16 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca /// /// /// + /// /// - private async ValueTask HandleClientResponseAsync(HttpContext context, string? sessionIdStr, JsonRpcResponse response) + private async ValueTask HandleClientResponseAsync(HttpContext context, string? sessionIdStr, string? protocolVersion, JsonRpcResponse response) { - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + var session = await GetExistingSessionAsync(context, sessionIdStr, protocolVersion); + if (session is null) { - Log.Warn($"[McpServer][TouchSocket] Response routing failed: Session not found. SessionId={sessionIdStr}"); - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; } + session.HandleResponseAsync(response); await context.RespondHttpSuccess(HttpStatusCode.Accepted); } @@ -568,17 +564,18 @@ private async ValueTask HandleClientResponseAsync(HttpContext context, string? s /// /// /// + /// /// /// /// - private async ValueTask HandleNotificationAsync(HttpContext context, string? sessionIdStr, JsonRpcNotification notification, HttpRequest request, CancellationToken cancellationToken) + private async ValueTask HandleNotificationAsync(HttpContext context, string? sessionIdStr, string? protocolVersion, JsonRpcNotification notification, HttpRequest request, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + var session = await GetExistingSessionAsync(context, sessionIdStr, protocolVersion); + if (session is null) { - Log.Warn($"[McpServer][TouchSocket] Notification routing failed: Session not found. SessionId={sessionIdStr}"); - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; } + await _manager.HandleRequestAsync( new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, s => @@ -595,12 +592,13 @@ await _manager.HandleRequestAsync( /// /// /// + /// /// /// /// - private async ValueTask HandleRpcRequestAsync(HttpContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest, HttpRequest request, CancellationToken cancellationToken) + private async ValueTask HandleRpcRequestAsync(HttpContext context, string? sessionIdStr, string? protocolVersion, JsonRpcRequest jsonRpcRequest, HttpRequest request, CancellationToken cancellationToken) { - var session = await GetOrCreateSessionAsync(context, sessionIdStr, jsonRpcRequest); + var session = await GetOrCreateSessionAsync(context, sessionIdStr, protocolVersion, jsonRpcRequest); if (session is null) return; Log.Debug($"[McpServer][TouchSocket] Handling JSON-RPC request. SessionId={session.SessionId}, Method={jsonRpcRequest.Method}, MessageId={jsonRpcRequest.Id}"); @@ -620,12 +618,18 @@ private async ValueTask HandleRpcRequestAsync(HttpContext context, string? sessi /// /// /// + /// /// /// - private async ValueTask GetOrCreateSessionAsync(HttpContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest) + private async ValueTask GetOrCreateSessionAsync(HttpContext context, string? sessionIdStr, string? protocolVersion, JsonRpcRequest jsonRpcRequest) { if (jsonRpcRequest.Method == RequestMethods.Initialize) { + if (!await ValidateProtocolVersionHeaderAsync(context, protocolVersion, null)) + { + return null; + } + var newSessionId = _manager.MakeNewSessionId(); var newSession = new HttpServerTransportSession(_manager, newSessionId.Id, "[McpServer][TouchSocket]"); if (_sessions.TryAdd(newSessionId.Id, newSession)) @@ -640,21 +644,55 @@ private async ValueTask HandleRpcRequestAsync(HttpContext context, string? sessi return null; } + return await GetExistingSessionAsync(context, sessionIdStr, protocolVersion); + } + + private async ValueTask GetExistingSessionAsync(HttpContext context, string? sessionIdStr, string? protocolVersion) + { if (string.IsNullOrEmpty(sessionIdStr)) { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header. Method={jsonRpcRequest.Method}"); + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Missing Mcp-Session-Id header."); await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); return null; } if (!_sessions.TryGetValue(sessionIdStr, out var session)) { - Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}, Method={jsonRpcRequest.Method}"); + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Session not found. SessionId={sessionIdStr}"); await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return null; } + + if (!await ValidateProtocolVersionHeaderAsync(context, protocolVersion, session.NegotiatedProtocolVersion)) + { + return null; + } + return session; } + private async ValueTask ValidateProtocolVersionHeaderAsync(HttpContext context, string? protocolVersion, ProtocolVersion? negotiatedProtocolVersion) + { + if (!string.IsNullOrEmpty(protocolVersion) && !ProtocolVersion.IsSupportedStreamableHttpVersion(protocolVersion)) + { + Log.Warn($"[McpServer][TouchSocket] Request rejected: Unsupported protocol version. Version={protocolVersion}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, + $"Unsupported protocol version. Supported versions: {string.Join(", ", ProtocolVersion.StreamableHttpSupportedVersions)}"); + return false; + } + + if (!string.IsNullOrEmpty(protocolVersion) + && negotiatedProtocolVersion is { } negotiated + && !string.Equals(protocolVersion, negotiated.ToString(), StringComparison.Ordinal)) + { + Log.Warn($"[McpServer][TouchSocket] Request rejected: Protocol version mismatch. Expected={negotiated}, Actual={protocolVersion}"); + await context.RespondHttpError(HttpStatusCode.BadRequest, + $"Protocol version mismatch. Expected: {negotiated}, Actual: {protocolVersion}"); + return false; + } + + return true; + } + /// /// initialize 请求:同步返回 application/json,无需 SSE 流。 /// @@ -730,7 +768,16 @@ private async ValueTask HandleSseRequestAsync(HttpContext context, HttpServerTra /// private async ValueTask HandleStreamableHttpDisconnectionAsync(HttpContext context) { - var sessionId = context.Request.Headers.Get(SessionIdHeader).First; + var request = context.Request; + var sessionId = request.Headers.Get(SessionIdHeader).First; + if (!string.IsNullOrEmpty(sessionId) && _sessions.TryGetValue(sessionId, out var existingSession)) + { + if (!await ValidateProtocolVersionHeaderAsync(context, request.Headers.Get(ProtocolVersionHeader).First, existingSession.NegotiatedProtocolVersion)) + { + return; + } + } + if (!string.IsNullOrEmpty(sessionId)) { if (_sessions.TryRemove(sessionId, out var session)) diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs index 5e87a90..1541107 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs @@ -52,6 +52,16 @@ internal McpClient(McpClientContext context) /// public required ClientCapabilities Capabilities { get; init; } + /// + /// 获取 initialize 时优先声明的协议版本。 + /// + public string PreferredProtocolVersion { get; init; } = ProtocolVersion.Current; + + /// + /// 获取客户端可接受的协议版本集合。 + /// + public IReadOnlyList SupportedProtocolVersions { get; init; } = ProtocolVersion.StreamableHttpSupportedVersions; + /// /// 获取服务器信息(初始化后可用)。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs index dec222f..71f847c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs @@ -1,4 +1,5 @@ using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Transports; using DotNetCampus.ModelContextProtocol.Transports.Http; @@ -20,6 +21,8 @@ public class McpClientBuilder private Func? _transportFactory; private ClientCapabilities _capabilities = new(); private Func>? _samplingHandler; + private string _preferredProtocolVersion = ProtocolVersion.Current; + private IReadOnlyList _supportedProtocolVersions = ProtocolVersion.StreamableHttpSupportedVersions; /// /// 设置客户端名称和版本。 @@ -103,10 +106,10 @@ public McpClientBuilder WithStdio(StdioClientTransportOptions options) /// 用于链式调用的 MCP 客户端生成器。 public McpClientBuilder WithHttp(string serverUrl) { - return WithTransport(m => new HttpClientTransport(m, new HttpClientTransportOptions + return WithHttp(new HttpClientTransportOptions { ServerUrl = serverUrl, - })); + }); } /// @@ -116,6 +119,9 @@ public McpClientBuilder WithHttp(string serverUrl) /// 用于链式调用的 MCP 客户端生成器。 public McpClientBuilder WithHttp(HttpClientTransportOptions options) { + _supportedProtocolVersions = NormalizeSupportedProtocolVersions(options.SupportedProtocolVersions); + _preferredProtocolVersion = NormalizePreferredProtocolVersion(options.PreferredProtocolVersion); + ValidateProtocolVersionConfiguration(_preferredProtocolVersion, _supportedProtocolVersions); return WithTransport(m => new HttpClientTransport(m, options)); } @@ -219,6 +225,56 @@ public McpClient Build() ClientName = _clientName, ClientVersion = _clientVersion, Capabilities = _capabilities, + PreferredProtocolVersion = _preferredProtocolVersion, + SupportedProtocolVersions = _supportedProtocolVersions, }; } + + private static IReadOnlyList NormalizeSupportedProtocolVersions(IReadOnlyList? supportedProtocolVersions) + { + if (supportedProtocolVersions is null || supportedProtocolVersions.Count == 0) + { + return ProtocolVersion.StreamableHttpSupportedVersions; + } + + var normalizedVersions = supportedProtocolVersions + .Where(static version => !string.IsNullOrWhiteSpace(version)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (normalizedVersions.Length == 0) + { + throw new InvalidOperationException("至少需要配置一个可接受的协议版本。"); + } + + foreach (var version in normalizedVersions) + { + if (!ProtocolVersion.IsSupportedStreamableHttpVersion(version)) + { + throw new InvalidOperationException($"当前 HTTP 客户端尚不支持协议版本 '{version}'。"); + } + } + + return normalizedVersions; + } + + private static string NormalizePreferredProtocolVersion(string? preferredProtocolVersion) + { + return string.IsNullOrWhiteSpace(preferredProtocolVersion) + ? ProtocolVersion.Current + : preferredProtocolVersion; + } + + private static void ValidateProtocolVersionConfiguration(string preferredProtocolVersion, IReadOnlyList supportedProtocolVersions) + { + if (!ProtocolVersion.IsSupportedStreamableHttpVersion(preferredProtocolVersion)) + { + throw new InvalidOperationException($"当前 HTTP 客户端尚不支持首选协议版本 '{preferredProtocolVersion}'。"); + } + + if (!supportedProtocolVersions.Contains(preferredProtocolVersion, StringComparer.Ordinal)) + { + throw new InvalidOperationException($"首选协议版本 '{preferredProtocolVersion}' 必须包含在支持版本集合中。"); + } + } } diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs index 93d5288..40e2a83 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs @@ -69,6 +69,16 @@ public static implicit operator string(ProtocolVersion version) /// public static readonly ProtocolVersion StreamableHttpMinimum = new(StreamableHttpMinimumVersion); + /// + /// Streamable HTTP 传输层当前支持的协议版本列表,按优先级从高到低排列。 + /// + public static IReadOnlyList StreamableHttpSupportedVersions { get; } = + [ + CurrentVersion, + "2025-06-18", + StreamableHttpMinimumVersion, + ]; + /// /// 历史版本列表,按时间倒序排列 /// @@ -79,4 +89,30 @@ public static implicit operator string(ProtocolVersion version) "2025-03-26", "2024-11-05", ]; + + /// + /// 判断指定版本是否为当前支持的 Streamable HTTP 协议版本。 + /// + public static bool IsSupportedStreamableHttpVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return false; + } + + return StreamableHttpSupportedVersions.Contains(version, StringComparer.Ordinal); + } + + /// + /// 根据客户端在 initialize 中声明的版本,协商出当前服务端将使用的 Streamable HTTP 协议版本。 + /// + public static ProtocolVersion NegotiateStreamableHttpVersion(string? clientVersion) + { + if (IsSupportedStreamableHttpVersion(clientVersion)) + { + return clientVersion!; + } + + return Current; + } } diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs index 68b76c7..11e109f 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs @@ -63,14 +63,20 @@ public virtual ValueTask InitializeAsync( CancellationToken cancellationToken) { var clientInfo = request.Params?.ClientInfo; + var negotiatedProtocolVersion = ProtocolVersion.NegotiateStreamableHttpVersion(request.Params?.ProtocolVersion); Logger.Info( - $"[McpServer][Mcp] Client initializing. ClientName={clientInfo?.Name}, ClientVersion={clientInfo?.Version}, ProtocolVersion={request.Params?.ProtocolVersion}"); + $"[McpServer][Mcp] Client initializing. ClientName={clientInfo?.Name}, ClientVersion={clientInfo?.Version}, RequestedProtocolVersion={request.Params?.ProtocolVersion}, NegotiatedProtocolVersion={negotiatedProtocolVersion}"); // 将客户端能力保存到当前传输层会话,以便后续服务器发起请求(如 sampling)时判断能力。 var session = (IServerTransportSession?)request.Services.GetService(typeof(IServerTransportSession)); - if (session is not null && request.Params?.Capabilities is { } capabilities) + if (session is not null) { - session.ConnectedClientCapabilities = capabilities; + session.NegotiatedProtocolVersion = negotiatedProtocolVersion; + + if (request.Params?.Capabilities is { } capabilities) + { + session.ConnectedClientCapabilities = capabilities; + } } var hasTools = _server.Tools.Count > 0; @@ -78,7 +84,7 @@ public virtual ValueTask InitializeAsync( var result = new InitializeResult { - ProtocolVersion = ProtocolVersion.Current, + ProtocolVersion = negotiatedProtocolVersion, ServerInfo = new Implementation { Name = _server.ServerName, @@ -99,7 +105,7 @@ public virtual ValueTask InitializeAsync( }; Logger.Info( - $"[McpServer][Mcp] Server initialized. ServerName={_server.ServerName}, ServerVersion={_server.ServerVersion}, ToolCount={_server.Tools.Count}, ResourceCount={_server.Resources.Count}"); + $"[McpServer][Mcp] Server initialized. ServerName={_server.ServerName}, ServerVersion={_server.ServerVersion}, ProtocolVersion={negotiatedProtocolVersion}, ToolCount={_server.Tools.Count}, ResourceCount={_server.Resources.Count}"); return ValueTask.FromResult(result); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 7eaea4a..403cf1a 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -274,7 +274,7 @@ public async ValueTask ConnectAndInitializeAsync(McpClient cli Method = RequestMethods.Initialize, Params = JsonSerializer.SerializeToElement(new InitializeRequestParams { - ProtocolVersion = ProtocolVersion.Current, + ProtocolVersion = client.PreferredProtocolVersion, ClientInfo = new Implementation { Name = client.ClientName, @@ -299,6 +299,17 @@ public async ValueTask ConnectAndInitializeAsync(McpClient cli var result = responseResult.Deserialize(McpInternalJsonContext.Default.InitializeResult) ?? throw new McpClientException("无法解析初始化响应"); + if (!client.SupportedProtocolVersions.Contains(result.ProtocolVersion, StringComparer.Ordinal)) + { + throw new McpClientException($"服务器返回了客户端不支持的协议版本:{result.ProtocolVersion}"); + } + + if (!string.Equals(result.ProtocolVersion, client.PreferredProtocolVersion, StringComparison.Ordinal)) + { + Context.Logger.Info( + $"[McpClient][Mcp] Protocol version negotiated. Requested={client.PreferredProtocolVersion}, Negotiated={result.ProtocolVersion}"); + } + // 发送 initialized 通知。 await SendNotificationAsync(new JsonRpcNotification { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs index 6ef1be9..0a9e84a 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs @@ -1,3 +1,5 @@ +using DotNetCampus.ModelContextProtocol.Protocol; + namespace DotNetCampus.ModelContextProtocol.Transports.Http; /// @@ -14,4 +16,14 @@ public class HttpClientTransportOptions /// 获取或设置自定义的 HttpClient 实例。如果未设置,将创建新的 。 /// public HttpClient? HttpClient { get; init; } + + /// + /// 获取或设置 initialize 时优先声明的协议版本。默认使用当前最新版本。 + /// + public string PreferredProtocolVersion { get; init; } = ProtocolVersion.Current; + + /// + /// 获取或设置客户端可接受的协议版本集合。默认使用当前实现支持的 Streamable HTTP 版本集合。 + /// + public IReadOnlyList? SupportedProtocolVersions { get; init; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index eca856d..6583613 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -381,16 +381,7 @@ private async Task HandleLegacyRpcRequestAsync(HttpListenerContext context, stri private async Task HandlePostRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) { var request = context.Request; - - // 协议版本检查 - // 按照 MCP 协议规范 §2.7:Streamable HTTP 传输层最低支持版本为 2025-03-26(该版本引入了 Streamable HTTP 传输层)。 - // If the server receives a request with an invalid or unsupported MCP-Protocol-Version, it MUST respond with 400 Bad Request. var protocolVersion = request.Headers[ProtocolVersionHeader]; - if (!string.IsNullOrEmpty(protocolVersion) && (ProtocolVersion)protocolVersion < ProtocolVersion.StreamableHttpMinimum) - { - await context.RespondHttpError(HttpStatusCode.BadRequest, $"Unsupported protocol version. Minimum required: {ProtocolVersion.StreamableHttpMinimum}"); - return; - } // 解析消息体 JsonRpcMessage? message; @@ -418,13 +409,13 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat switch (message) { case JsonRpcResponse jsonRpcResponse: - await HandleClientResponseAsync(context, sessionIdStr, jsonRpcResponse); + await HandleClientResponseAsync(context, sessionIdStr, protocolVersion, jsonRpcResponse); return; case JsonRpcNotification notification: - await HandleNotificationAsync(context, sessionIdStr, notification, cancellationToken); + await HandleNotificationAsync(context, sessionIdStr, protocolVersion, notification, cancellationToken); return; case JsonRpcRequest jsonRpcRequest: - await HandleRpcRequestAsync(context, sessionIdStr, jsonRpcRequest, cancellationToken); + await HandleRpcRequestAsync(context, sessionIdStr, protocolVersion, jsonRpcRequest, cancellationToken); return; default: await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid or unrecognized JSON-RPC message"); @@ -437,14 +428,16 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat /// /// /// + /// /// - private async Task HandleClientResponseAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcResponse response) + private async Task HandleClientResponseAsync(HttpListenerContext context, string? sessionIdStr, string? protocolVersion, JsonRpcResponse response) { - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + var session = await GetExistingSessionAsync(context, sessionIdStr, protocolVersion); + if (session is null) { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; } + session.HandleResponseAsync(response); context.RespondHttpSuccess(HttpStatusCode.Accepted); } @@ -454,16 +447,18 @@ private async Task HandleClientResponseAsync(HttpListenerContext context, string /// /// /// + /// /// /// - private async Task HandleNotificationAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcNotification notification, + private async Task HandleNotificationAsync(HttpListenerContext context, string? sessionIdStr, string? protocolVersion, JsonRpcNotification notification, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(sessionIdStr) || !_sessions.TryGetValue(sessionIdStr, out var session)) + var session = await GetExistingSessionAsync(context, sessionIdStr, protocolVersion); + if (session is null) { - await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return; } + await _manager.HandleRequestAsync( new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, s => s.AddTransportSession(session, Log), @@ -476,12 +471,13 @@ await _manager.HandleRequestAsync( ///
/// /// + /// /// /// - private async Task HandleRpcRequestAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest, + private async Task HandleRpcRequestAsync(HttpListenerContext context, string? sessionIdStr, string? protocolVersion, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) { - var session = await GetOrCreateSessionAsync(context, sessionIdStr, jsonRpcRequest); + var session = await GetOrCreateSessionAsync(context, sessionIdStr, protocolVersion, jsonRpcRequest); if (session is null) return; if (jsonRpcRequest.Method == RequestMethods.Initialize) @@ -499,12 +495,18 @@ private async Task HandleRpcRequestAsync(HttpListenerContext context, string? se ///
/// /// + /// /// /// - private async Task GetOrCreateSessionAsync(HttpListenerContext context, string? sessionIdStr, JsonRpcRequest jsonRpcRequest) + private async Task GetOrCreateSessionAsync(HttpListenerContext context, string? sessionIdStr, string? protocolVersion, JsonRpcRequest jsonRpcRequest) { if (jsonRpcRequest.Method == RequestMethods.Initialize) { + if (!await ValidateProtocolVersionHeaderAsync(context, protocolVersion, null)) + { + return null; + } + var newSessionId = _manager.MakeNewSessionId(); var newSession = new HttpServerTransportSession(_manager, newSessionId.Id, "[McpServer][StreamableHttp]"); if (_sessions.TryAdd(newSessionId.Id, newSession)) @@ -517,6 +519,11 @@ private async Task HandleRpcRequestAsync(HttpListenerContext context, string? se return null; } + return await GetExistingSessionAsync(context, sessionIdStr, protocolVersion); + } + + private async Task GetExistingSessionAsync(HttpListenerContext context, string? sessionIdStr, string? protocolVersion) + { if (string.IsNullOrEmpty(sessionIdStr)) { await context.RespondHttpError(HttpStatusCode.BadRequest, "Missing Mcp-Session-Id header"); @@ -527,9 +534,36 @@ private async Task HandleRpcRequestAsync(HttpListenerContext context, string? se await context.RespondHttpError(HttpStatusCode.NotFound, "Session not found"); return null; } + + if (!await ValidateProtocolVersionHeaderAsync(context, protocolVersion, session.NegotiatedProtocolVersion)) + { + return null; + } + return session; } + private async Task ValidateProtocolVersionHeaderAsync(HttpListenerContext context, string? protocolVersion, ProtocolVersion? negotiatedProtocolVersion) + { + if (!string.IsNullOrEmpty(protocolVersion) && !ProtocolVersion.IsSupportedStreamableHttpVersion(protocolVersion)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, + $"Unsupported protocol version. Supported versions: {string.Join(", ", ProtocolVersion.StreamableHttpSupportedVersions)}"); + return false; + } + + if (!string.IsNullOrEmpty(protocolVersion) + && negotiatedProtocolVersion is { } negotiated + && !string.Equals(protocolVersion, negotiated.ToString(), StringComparison.Ordinal)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, + $"Protocol version mismatch. Expected: {negotiated}, Actual: {protocolVersion}"); + return false; + } + + return true; + } + /// /// initialize 请求:同步返回 application/json,无需 SSE 流。 /// @@ -628,6 +662,11 @@ private async Task HandleGetRequestAsync(HttpListenerContext context, Cancellati return; } + if (!await ValidateProtocolVersionHeaderAsync(context, request.Headers[ProtocolVersionHeader], session.NegotiatedProtocolVersion)) + { + return; + } + context.Response.StatusCode = (int)HttpStatusCode.OK; context.Response.ContentType = "text/event-stream"; context.Response.Headers["Cache-Control"] = "no-cache"; @@ -668,6 +707,14 @@ private async Task HandleGetRequestAsync(HttpListenerContext context, Cancellati private async Task HandleDeleteRequestAsync(HttpListenerContext context) { var sessionId = context.Request.Headers[SessionIdHeader]; + if (!string.IsNullOrEmpty(sessionId) && _sessions.TryGetValue(sessionId, out var existingSession)) + { + if (!await ValidateProtocolVersionHeaderAsync(context, context.Request.Headers[ProtocolVersionHeader], existingSession.NegotiatedProtocolVersion)) + { + return; + } + } + if (!string.IsNullOrEmpty(sessionId)) { if (_sessions.TryRemove(sessionId, out var session)) diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs index ce3cccc..fd1c9c4 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/IServerTransportSession.cs @@ -1,4 +1,5 @@ -using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports; @@ -20,6 +21,11 @@ public interface IServerTransportSession : IAsyncDisposable ///
ClientCapabilities? ConnectedClientCapabilities { get; set; } + /// + /// 当前会话协商出的协议版本。在 Initialize 握手完成后设置。 + /// + ProtocolVersion? NegotiatedProtocolVersion { get; set; } + /// /// 向客户端发送 JSON-RPC 请求并等待响应。用于服务器主动发起的请求(如 sampling/createMessage)。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs index fdd13ca..27f0be1 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ServerTransportSession.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; @@ -21,6 +22,9 @@ public abstract class ServerTransportSession : IServerTransportSession /// public ClientCapabilities? ConnectedClientCapabilities { get; set; } + /// + public ProtocolVersion? NegotiatedProtocolVersion { get; set; } + /// public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs index d132d79..40711ee 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using DotNetCampus.ModelContextProtocol.Clients; using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Transports.Http; namespace DotNetCampus.ModelContextProtocol.Tests.Clients; @@ -29,6 +31,29 @@ public async Task Initialize(HttpTransportType transportType) Assert.AreEqual("TestMcpServer", package.Client.ServerInfo.ServerInfo.Name); } + [TestMethod("Initialize: 可按客户端配置协商较低的 HTTP 协议版本")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task Initialize_WithConfiguredProtocolVersion(HttpTransportType transportType) + { + await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(transportType); + await using var client = new McpClientBuilder() + .WithLogger(TestMcpFactory.DefaultLogger) + .WithHttp(new HttpClientTransportOptions + { + ServerUrl = package.Endpoint.AbsoluteUri, + PreferredProtocolVersion = "2025-06-18", + SupportedProtocolVersions = ["2025-11-25", "2025-06-18", "2025-03-26"], + }) + .Build(); + + var result = await client.ListToolsAsync(); + + Assert.IsTrue(result.Tools.Count > 0); + Assert.IsNotNull(client.ServerInfo); + Assert.AreEqual("2025-06-18", client.ServerInfo.ProtocolVersion); + } + [TestMethod("Ping: 握手后连接状态正常")] [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs index c368e24..e2424fb 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs @@ -209,13 +209,15 @@ public async ValueTask CreateHttpCoreAsync( mcpServer.EnableDebugMode(); await mcpServer.StartAsync(CancellationToken.None); + var endpoint = new Uri($"http://127.0.0.1:{port}/mcp", UriKind.Absolute); + var mcpClientBuilder = new McpClientBuilder() .WithLogger(DefaultLogger) - .WithHttp($"http://127.0.0.1:{port}/mcp"); + .WithHttp(endpoint.AbsoluteUri); configureClient?.Invoke(mcpClientBuilder); var builtClient = mcpClientBuilder.Build(); - return new McpTestingPackage(mcpServer, builtClient); + return new McpTestingPackage(mcpServer, builtClient, endpoint); } private static IServiceProvider CreateDefaultServices() @@ -227,16 +229,19 @@ private static IServiceProvider CreateDefaultServices() public class McpTestingPackage : IAsyncDisposable { - public McpTestingPackage(McpServer server, McpClient client) + public McpTestingPackage(McpServer server, McpClient client, Uri endpoint) { Server = server; Client = client; + Endpoint = endpoint; } public McpServer Server { get; } public McpClient Client { get; } + public Uri Endpoint { get; } + public async ValueTask DisposeAsync() { // 停止客户端。 diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs index 7f2cfa0..b38dee8 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs @@ -106,6 +106,72 @@ public async Task Delete_TerminateSession(HttpTransportType type) Assert.IsFalse(package.Client.IsConnected); } + [TestMethod("StreamableHttp_NegotiatedVersionMustMatchSubsequentHeader: 协商结果应贯穿后续请求")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task StreamableHttp_NegotiatedVersionMustMatchSubsequentHeader(HttpTransportType type) + { + await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(type); + using var client = CreateHttpClient(); + + using var initializeRequest = CreateStreamableHttpRequest(HttpMethod.Post, package.Endpoint); + initializeRequest.Content = CreateInitializeRequestContent("2025-06-18"); + + using var initializeResponse = await client.SendAsync(initializeRequest); + + Assert.AreEqual(HttpStatusCode.OK, initializeResponse.StatusCode); + Assert.IsTrue(initializeResponse.Headers.TryGetValues("Mcp-Session-Id", out var sessionHeaders)); + var sessionId = sessionHeaders.Single(); + + using (var document = JsonDocument.Parse(await initializeResponse.Content.ReadAsStringAsync())) + { + Assert.AreEqual("2025-06-18", document.RootElement.GetProperty("result").GetProperty("protocolVersion").GetString()); + } + + using var mismatchRequest = CreateStreamableHttpRequest(HttpMethod.Post, package.Endpoint); + mismatchRequest.Headers.Add("Mcp-Session-Id", sessionId); + mismatchRequest.Headers.Add("Mcp-Protocol-Version", "2025-11-25"); + mismatchRequest.Content = CreateInitializedNotificationContent(); + + using var mismatchResponse = await client.SendAsync(mismatchRequest); + + Assert.AreEqual(HttpStatusCode.BadRequest, mismatchResponse.StatusCode); + } + + [TestMethod("StreamableHttp_GetAndDeleteMustMatchNegotiatedHeader: GET 与 DELETE 也应遵守协商结果")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task StreamableHttp_GetAndDeleteMustMatchNegotiatedHeader(HttpTransportType type) + { + await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(type); + using var client = CreateHttpClient(); + + using var initializeRequest = CreateStreamableHttpRequest(HttpMethod.Post, package.Endpoint); + initializeRequest.Content = CreateInitializeRequestContent("2025-06-18"); + + using var initializeResponse = await client.SendAsync(initializeRequest); + + Assert.AreEqual(HttpStatusCode.OK, initializeResponse.StatusCode); + Assert.IsTrue(initializeResponse.Headers.TryGetValues("Mcp-Session-Id", out var sessionHeaders)); + var sessionId = sessionHeaders.Single(); + + using var getRequest = CreateStreamableHttpRequest(HttpMethod.Get, package.Endpoint); + getRequest.Headers.Add("Mcp-Session-Id", sessionId); + getRequest.Headers.Add("Mcp-Protocol-Version", "2025-11-25"); + + using var getResponse = await client.SendAsync(getRequest, HttpCompletionOption.ResponseHeadersRead); + + Assert.AreEqual(HttpStatusCode.BadRequest, getResponse.StatusCode); + + using var deleteRequest = CreateStreamableHttpRequest(HttpMethod.Delete, package.Endpoint); + deleteRequest.Headers.Add("Mcp-Session-Id", sessionId); + deleteRequest.Headers.Add("Mcp-Protocol-Version", "2025-11-25"); + + using var deleteResponse = await client.SendAsync(deleteRequest); + + Assert.AreEqual(HttpStatusCode.BadRequest, deleteResponse.StatusCode); + } + private static HttpClient CreateHttpClient() { return new HttpClient @@ -114,16 +180,32 @@ private static HttpClient CreateHttpClient() }; } - private static StringContent CreateInitializeRequestContent() + private static StringContent CreateInitializeRequestContent(string protocolVersion = "2024-11-05") + { + return new StringContent( + $"{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{{\"protocolVersion\":\"{protocolVersion}\",\"capabilities\":{{}},\"clientInfo\":{{\"name\":\"legacy-test-client\",\"version\":\"1.0.0\"}}}}}}", + Encoding.UTF8, + "application/json"); + } + + private static StringContent CreateInitializedNotificationContent() { return new StringContent( """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"legacy-test-client","version":"1.0.0"}}} + {"jsonrpc":"2.0","method":"notifications/initialized"} """, Encoding.UTF8, "application/json"); } + private static HttpRequestMessage CreateStreamableHttpRequest(HttpMethod method, Uri endpoint) + { + var request = new HttpRequestMessage(method, endpoint); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + return request; + } + private static Uri ResolveEndpoint(Uri baseEndpoint, string endpoint) { return Uri.TryCreate(endpoint, UriKind.Absolute, out var absoluteUri) From d200b5825e63467dbe271016c873fe5c45f8c386 Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 18:36:39 +0800 Subject: [PATCH 46/77] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=92=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TouchSocketHttpServerTransport.cs | 26 +++++++- .../Clients/McpClient.cs | 10 --- .../Clients/McpClientBuilder.cs | 56 ----------------- .../Protocol/ProtocolVersion.cs | 29 +++++---- .../Transports/ClientTransportManager.cs | 8 +-- .../Http/HttpClientTransportOptions.cs | 11 ---- .../Http/LocalHostHttpServerTransport.cs | 62 ++++++++++++++----- .../Clients/CoreTests.cs | 25 -------- .../Transports/HttpTransportTests.cs | 21 +++++++ 9 files changed, 115 insertions(+), 133 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 5563423..2fe9057 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -372,9 +372,17 @@ private async ValueTask HandleLegacySseConnectionAsync(HttpContext context, Canc private async ValueTask HandleLegacyMessageAsync(HttpContext context, CancellationToken cancellationToken) { JsonRpcMessage? message; + ReadOnlyMemory bodyBytes; try { - var bodyBytes = await context.Request.GetContentAsync(); + bodyBytes = await context.Request.GetContentAsync(); + + if (IsBatchMessage(bodyBytes.Span)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Batch JSON-RPC messages are not supported yet."); + return; + } + message = await _manager.ReadMessageAsync(bodyBytes); } catch (JsonException) @@ -508,6 +516,14 @@ private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, Ca try { var bodyBytes = await request.GetContentAsync(); + + if (IsBatchMessage(bodyBytes.Span)) + { + Log.Warn($"[McpServer][TouchSocket] POST request rejected: Batch JSON-RPC messages are not supported yet."); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Batch JSON-RPC messages are not supported yet."); + return; + } + message = await _manager.ReadMessageAsync(bodyBytes); } catch (JsonException) @@ -676,7 +692,7 @@ private async ValueTask ValidateProtocolVersionHeaderAsync(HttpContext con { Log.Warn($"[McpServer][TouchSocket] Request rejected: Unsupported protocol version. Version={protocolVersion}"); await context.RespondHttpError(HttpStatusCode.BadRequest, - $"Unsupported protocol version. Supported versions: {string.Join(", ", ProtocolVersion.StreamableHttpSupportedVersions)}"); + $"Unsupported protocol version. Supported range: {ProtocolVersion.StreamableHttpMinimum} to {ProtocolVersion.Current}"); return false; } @@ -693,6 +709,12 @@ await context.RespondHttpError(HttpStatusCode.BadRequest, return true; } + private static bool IsBatchMessage(ReadOnlySpan requestBody) + { + using var document = JsonDocument.Parse(requestBody.ToArray()); + return document.RootElement.ValueKind == JsonValueKind.Array; + } + /// /// initialize 请求:同步返回 application/json,无需 SSE 流。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs index 1541107..5e87a90 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClient.cs @@ -52,16 +52,6 @@ internal McpClient(McpClientContext context) ///
public required ClientCapabilities Capabilities { get; init; } - /// - /// 获取 initialize 时优先声明的协议版本。 - /// - public string PreferredProtocolVersion { get; init; } = ProtocolVersion.Current; - - /// - /// 获取客户端可接受的协议版本集合。 - /// - public IReadOnlyList SupportedProtocolVersions { get; init; } = ProtocolVersion.StreamableHttpSupportedVersions; - /// /// 获取服务器信息(初始化后可用)。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs index 71f847c..dc7293d 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs @@ -1,5 +1,4 @@ using DotNetCampus.ModelContextProtocol.Hosting.Logging; -using DotNetCampus.ModelContextProtocol.Protocol; using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Transports; using DotNetCampus.ModelContextProtocol.Transports.Http; @@ -21,8 +20,6 @@ public class McpClientBuilder private Func? _transportFactory; private ClientCapabilities _capabilities = new(); private Func>? _samplingHandler; - private string _preferredProtocolVersion = ProtocolVersion.Current; - private IReadOnlyList _supportedProtocolVersions = ProtocolVersion.StreamableHttpSupportedVersions; /// /// 设置客户端名称和版本。 @@ -119,9 +116,6 @@ public McpClientBuilder WithHttp(string serverUrl) /// 用于链式调用的 MCP 客户端生成器。 public McpClientBuilder WithHttp(HttpClientTransportOptions options) { - _supportedProtocolVersions = NormalizeSupportedProtocolVersions(options.SupportedProtocolVersions); - _preferredProtocolVersion = NormalizePreferredProtocolVersion(options.PreferredProtocolVersion); - ValidateProtocolVersionConfiguration(_preferredProtocolVersion, _supportedProtocolVersions); return WithTransport(m => new HttpClientTransport(m, options)); } @@ -225,56 +219,6 @@ public McpClient Build() ClientName = _clientName, ClientVersion = _clientVersion, Capabilities = _capabilities, - PreferredProtocolVersion = _preferredProtocolVersion, - SupportedProtocolVersions = _supportedProtocolVersions, }; } - - private static IReadOnlyList NormalizeSupportedProtocolVersions(IReadOnlyList? supportedProtocolVersions) - { - if (supportedProtocolVersions is null || supportedProtocolVersions.Count == 0) - { - return ProtocolVersion.StreamableHttpSupportedVersions; - } - - var normalizedVersions = supportedProtocolVersions - .Where(static version => !string.IsNullOrWhiteSpace(version)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - - if (normalizedVersions.Length == 0) - { - throw new InvalidOperationException("至少需要配置一个可接受的协议版本。"); - } - - foreach (var version in normalizedVersions) - { - if (!ProtocolVersion.IsSupportedStreamableHttpVersion(version)) - { - throw new InvalidOperationException($"当前 HTTP 客户端尚不支持协议版本 '{version}'。"); - } - } - - return normalizedVersions; - } - - private static string NormalizePreferredProtocolVersion(string? preferredProtocolVersion) - { - return string.IsNullOrWhiteSpace(preferredProtocolVersion) - ? ProtocolVersion.Current - : preferredProtocolVersion; - } - - private static void ValidateProtocolVersionConfiguration(string preferredProtocolVersion, IReadOnlyList supportedProtocolVersions) - { - if (!ProtocolVersion.IsSupportedStreamableHttpVersion(preferredProtocolVersion)) - { - throw new InvalidOperationException($"当前 HTTP 客户端尚不支持首选协议版本 '{preferredProtocolVersion}'。"); - } - - if (!supportedProtocolVersions.Contains(preferredProtocolVersion, StringComparer.Ordinal)) - { - throw new InvalidOperationException($"首选协议版本 '{preferredProtocolVersion}' 必须包含在支持版本集合中。"); - } - } } diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs index 40e2a83..851b0cd 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs @@ -50,6 +50,22 @@ public static implicit operator string(ProtocolVersion version) return string.Compare(left.ToString(), right.ToString(), StringComparison.Ordinal) < 0; } + /// + /// 比较两个协议版本,判断左侧是否大于或等于右侧。 + /// + public static bool operator >=(ProtocolVersion left, ProtocolVersion right) + { + return string.Compare(left.ToString(), right.ToString(), StringComparison.Ordinal) >= 0; + } + + /// + /// 比较两个协议版本,判断左侧是否小于或等于右侧。 + /// + public static bool operator <=(ProtocolVersion left, ProtocolVersion right) + { + return string.Compare(left.ToString(), right.ToString(), StringComparison.Ordinal) <= 0; + } + private const string CurrentVersion = "2025-11-25"; private const string MinimumVersion = "2024-11-05"; private const string StreamableHttpMinimumVersion = "2025-03-26"; @@ -69,16 +85,6 @@ public static implicit operator string(ProtocolVersion version) /// public static readonly ProtocolVersion StreamableHttpMinimum = new(StreamableHttpMinimumVersion); - /// - /// Streamable HTTP 传输层当前支持的协议版本列表,按优先级从高到低排列。 - /// - public static IReadOnlyList StreamableHttpSupportedVersions { get; } = - [ - CurrentVersion, - "2025-06-18", - StreamableHttpMinimumVersion, - ]; - /// /// 历史版本列表,按时间倒序排列 /// @@ -100,7 +106,8 @@ public static bool IsSupportedStreamableHttpVersion(string? version) return false; } - return StreamableHttpSupportedVersions.Contains(version, StringComparer.Ordinal); + var protocolVersion = (ProtocolVersion)version; + return protocolVersion >= StreamableHttpMinimum; } /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 403cf1a..52008f9 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -274,7 +274,7 @@ public async ValueTask ConnectAndInitializeAsync(McpClient cli Method = RequestMethods.Initialize, Params = JsonSerializer.SerializeToElement(new InitializeRequestParams { - ProtocolVersion = client.PreferredProtocolVersion, + ProtocolVersion = ProtocolVersion.Current, ClientInfo = new Implementation { Name = client.ClientName, @@ -299,15 +299,15 @@ public async ValueTask ConnectAndInitializeAsync(McpClient cli var result = responseResult.Deserialize(McpInternalJsonContext.Default.InitializeResult) ?? throw new McpClientException("无法解析初始化响应"); - if (!client.SupportedProtocolVersions.Contains(result.ProtocolVersion, StringComparer.Ordinal)) + if (!ProtocolVersion.IsSupportedStreamableHttpVersion(result.ProtocolVersion)) { throw new McpClientException($"服务器返回了客户端不支持的协议版本:{result.ProtocolVersion}"); } - if (!string.Equals(result.ProtocolVersion, client.PreferredProtocolVersion, StringComparison.Ordinal)) + if (!string.Equals(result.ProtocolVersion, ProtocolVersion.Current, StringComparison.Ordinal)) { Context.Logger.Info( - $"[McpClient][Mcp] Protocol version negotiated. Requested={client.PreferredProtocolVersion}, Negotiated={result.ProtocolVersion}"); + $"[McpClient][Mcp] Protocol version negotiated. Requested={ProtocolVersion.Current}, Negotiated={result.ProtocolVersion}"); } // 发送 initialized 通知。 diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs index 0a9e84a..3368055 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransportOptions.cs @@ -1,5 +1,3 @@ -using DotNetCampus.ModelContextProtocol.Protocol; - namespace DotNetCampus.ModelContextProtocol.Transports.Http; /// @@ -17,13 +15,4 @@ public class HttpClientTransportOptions /// public HttpClient? HttpClient { get; init; } - /// - /// 获取或设置 initialize 时优先声明的协议版本。默认使用当前最新版本。 - /// - public string PreferredProtocolVersion { get; init; } = ProtocolVersion.Current; - - /// - /// 获取或设置客户端可接受的协议版本集合。默认使用当前实现支持的 Streamable HTTP 版本集合。 - /// - public IReadOnlyList? SupportedProtocolVersions { get; init; } } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 6583613..7046680 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -260,21 +260,28 @@ private async Task HandleLegacyGetRequestAsync(HttpListenerContext context, Canc private async Task HandleLegacyPostRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) { + var requestBody = await TryReadRequestBodyAsync(context.Request.InputStream, context, cancellationToken); + if (requestBody is null) + { + return; + } + + if (IsBatchMessage(requestBody.Value.Span)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Batch JSON-RPC messages are not supported yet."); + return; + } + JsonRpcMessage? message; try { - message = await _manager.ReadMessageAsync(context.Request.InputStream); + message = await _manager.ReadMessageAsync(requestBody.Value); } catch (JsonException) { await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); return; } - catch - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Failed to read request body"); - return; - } var sessionId = context.Request.QueryString["sessionId"]; if (message is not null) @@ -383,22 +390,28 @@ private async Task HandlePostRequestAsync(HttpListenerContext context, Cancellat var request = context.Request; var protocolVersion = request.Headers[ProtocolVersionHeader]; - // 解析消息体 + var requestBody = await TryReadRequestBodyAsync(request.InputStream, context, cancellationToken); + if (requestBody is null) + { + return; + } + + if (IsBatchMessage(requestBody.Value.Span)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Batch JSON-RPC messages are not supported yet."); + return; + } + JsonRpcMessage? message; try { - message = await _manager.ReadMessageAsync(request.InputStream); + message = await _manager.ReadMessageAsync(requestBody.Value); } catch (JsonException) { await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid JSON"); return; } - catch - { - await context.RespondHttpError(HttpStatusCode.BadRequest, "Failed to read request body"); - return; - } var sessionIdStr = request.Headers[SessionIdHeader]; @@ -548,7 +561,7 @@ private async Task ValidateProtocolVersionHeaderAsync(HttpListenerContext if (!string.IsNullOrEmpty(protocolVersion) && !ProtocolVersion.IsSupportedStreamableHttpVersion(protocolVersion)) { await context.RespondHttpError(HttpStatusCode.BadRequest, - $"Unsupported protocol version. Supported versions: {string.Join(", ", ProtocolVersion.StreamableHttpSupportedVersions)}"); + $"Unsupported protocol version. Supported range: {ProtocolVersion.StreamableHttpMinimum} to {ProtocolVersion.Current}"); return false; } @@ -564,6 +577,27 @@ await context.RespondHttpError(HttpStatusCode.BadRequest, return true; } + private static bool IsBatchMessage(ReadOnlySpan requestBody) + { + using var document = JsonDocument.Parse(requestBody.ToArray()); + return document.RootElement.ValueKind == JsonValueKind.Array; + } + + private static async Task?> TryReadRequestBodyAsync(Stream inputStream, HttpListenerContext context, CancellationToken cancellationToken) + { + try + { + using var memoryStream = new MemoryStream(); + await inputStream.CopyToAsync(memoryStream, cancellationToken); + return memoryStream.ToArray(); + } + catch + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Failed to read request body"); + return null; + } + } + /// /// initialize 请求:同步返回 application/json,无需 SSE 流。 /// diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs index 40711ee..d132d79 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/CoreTests.cs @@ -1,7 +1,5 @@ using System.Text.Json; -using DotNetCampus.ModelContextProtocol.Clients; using DotNetCampus.ModelContextProtocol.Protocol.Messages; -using DotNetCampus.ModelContextProtocol.Transports.Http; namespace DotNetCampus.ModelContextProtocol.Tests.Clients; @@ -31,29 +29,6 @@ public async Task Initialize(HttpTransportType transportType) Assert.AreEqual("TestMcpServer", package.Client.ServerInfo.ServerInfo.Name); } - [TestMethod("Initialize: 可按客户端配置协商较低的 HTTP 协议版本")] - [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] - [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] - public async Task Initialize_WithConfiguredProtocolVersion(HttpTransportType transportType) - { - await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(transportType); - await using var client = new McpClientBuilder() - .WithLogger(TestMcpFactory.DefaultLogger) - .WithHttp(new HttpClientTransportOptions - { - ServerUrl = package.Endpoint.AbsoluteUri, - PreferredProtocolVersion = "2025-06-18", - SupportedProtocolVersions = ["2025-11-25", "2025-06-18", "2025-03-26"], - }) - .Build(); - - var result = await client.ListToolsAsync(); - - Assert.IsTrue(result.Tools.Count > 0); - Assert.IsNotNull(client.ServerInfo); - Assert.AreEqual("2025-06-18", client.ServerInfo.ProtocolVersion); - } - [TestMethod("Ping: 握手后连接状态正常")] [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs index b38dee8..1453d5c 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs @@ -172,6 +172,27 @@ public async Task StreamableHttp_GetAndDeleteMustMatchNegotiatedHeader(HttpTrans Assert.AreEqual(HttpStatusCode.BadRequest, deleteResponse.StatusCode); } + [TestMethod("StreamableHttp_BatchRequest_IsExplicitlyRejected: batch 请求边界应明确")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task StreamableHttp_BatchRequest_IsExplicitlyRejected(HttpTransportType type) + { + await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(type); + using var client = CreateHttpClient(); + + using var request = CreateStreamableHttpRequest(HttpMethod.Post, package.Endpoint); + request.Content = new StringContent( + """ + [{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"batch-client","version":"1.0.0"}}}] + """, + Encoding.UTF8, + "application/json"); + + using var response = await client.SendAsync(request); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + } + private static HttpClient CreateHttpClient() { return new HttpClient From ab43daf54283d738fbe0fb0eca2ea204f99d0cdc Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 21:36:31 +0800 Subject: [PATCH 47/77] =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E5=AE=98=E6=96=B9=20MC?= =?UTF-8?q?P=20=E6=96=87=E6=A1=A3=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TouchSocketHttpServerTransport.cs | 29 +- .../Protocol/ProtocolVersion.cs | 18 +- .../Transports/ClientTransportManager.cs | 9 + .../Transports/Http/HttpClientTransport.cs | 139 ++++++- .../Http/LocalHostHttpServerTransport.cs | 12 +- .../Clients/HttpClientNegotiationTests.cs | 359 ++++++++++++++++++ .../Transports/HttpTransportTests.cs | 77 ++++ 7 files changed, 627 insertions(+), 16 deletions(-) create mode 100644 tests/DotNetCampus.ModelContextProtocol.Tests/Clients/HttpClientNegotiationTests.cs diff --git a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs index 2fe9057..915c3c9 100644 --- a/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs @@ -262,7 +262,7 @@ private async ValueTask HandleStreamableHttpConnectionAsync(HttpContext context, return; } - if (!await ValidateProtocolVersionHeaderAsync(context, request.Headers.Get(ProtocolVersionHeader).First, session.NegotiatedProtocolVersion)) + if (!await ValidateProtocolVersionHeaderAsync(context, GetOptionalHeaderValue(request, ProtocolVersionHeader), session.NegotiatedProtocolVersion)) { return; } @@ -507,7 +507,7 @@ private async ValueTask HandleLegacyRpcRequestAsync(HttpContext context, string? private async ValueTask HandleStreamableHttpMessageAsync(HttpContext context, CancellationToken cancellationToken) { var request = context.Request; - var protocolVersion = request.Headers.Get(ProtocolVersionHeader).First; + var protocolVersion = GetOptionalHeaderValue(request, ProtocolVersionHeader); var sessionIdStr = request.Headers.Get(SessionIdHeader).First; @@ -688,15 +688,22 @@ private async ValueTask HandleRpcRequestAsync(HttpContext context, string? sessi private async ValueTask ValidateProtocolVersionHeaderAsync(HttpContext context, string? protocolVersion, ProtocolVersion? negotiatedProtocolVersion) { - if (!string.IsNullOrEmpty(protocolVersion) && !ProtocolVersion.IsSupportedStreamableHttpVersion(protocolVersion)) + if (protocolVersion is not null && string.IsNullOrWhiteSpace(protocolVersion)) + { + Log.Warn($"[McpServer][TouchSocket] Request rejected: Invalid protocol version header."); + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid protocol version header."); + return false; + } + + if (protocolVersion is not null && !ProtocolVersion.IsSupportedStreamableHttpVersion(protocolVersion)) { Log.Warn($"[McpServer][TouchSocket] Request rejected: Unsupported protocol version. Version={protocolVersion}"); await context.RespondHttpError(HttpStatusCode.BadRequest, - $"Unsupported protocol version. Supported range: {ProtocolVersion.StreamableHttpMinimum} to {ProtocolVersion.Current}"); + $"Unsupported protocol version. Supported versions: {ProtocolVersion.SupportedStreamableHttpVersionList}"); return false; } - if (!string.IsNullOrEmpty(protocolVersion) + if (protocolVersion is not null && negotiatedProtocolVersion is { } negotiated && !string.Equals(protocolVersion, negotiated.ToString(), StringComparison.Ordinal)) { @@ -794,7 +801,7 @@ private async ValueTask HandleStreamableHttpDisconnectionAsync(HttpContext conte var sessionId = request.Headers.Get(SessionIdHeader).First; if (!string.IsNullOrEmpty(sessionId) && _sessions.TryGetValue(sessionId, out var existingSession)) { - if (!await ValidateProtocolVersionHeaderAsync(context, request.Headers.Get(ProtocolVersionHeader).First, existingSession.NegotiatedProtocolVersion)) + if (!await ValidateProtocolVersionHeaderAsync(context, GetOptionalHeaderValue(request, ProtocolVersionHeader), existingSession.NegotiatedProtocolVersion)) { return; } @@ -835,6 +842,16 @@ private static string GetPathWithoutQuery(string rawEndpoint) return queryIndex < 0 ? rawEndpoint : rawEndpoint[..queryIndex]; } + private static string? GetOptionalHeaderValue(HttpRequest request, string headerName) + { + if (!request.Headers.ContainsKey(headerName)) + { + return null; + } + + return request.Headers.Get(headerName).First ?? string.Empty; + } + /// /// 按照 MCP 官方协议规范对传输层的要求:
diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs index 851b0cd..a97dd79 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/ProtocolVersion.cs @@ -85,6 +85,21 @@ public static implicit operator string(ProtocolVersion version) ///
public static readonly ProtocolVersion StreamableHttpMinimum = new(StreamableHttpMinimumVersion); + /// + /// 当前明确支持的 Streamable HTTP 协议版本列表。 + /// + public static IReadOnlyList SupportedStreamableHttpVersions { get; } = + [ + "2025-11-25", + "2025-06-18", + "2025-03-26", + ]; + + /// + /// 以逗号分隔的 Streamable HTTP 支持版本字符串,可用于错误提示。 + /// + public static string SupportedStreamableHttpVersionList => string.Join(", ", SupportedStreamableHttpVersions); + /// /// 历史版本列表,按时间倒序排列 /// @@ -106,8 +121,7 @@ public static bool IsSupportedStreamableHttpVersion(string? version) return false; } - var protocolVersion = (ProtocolVersion)version; - return protocolVersion >= StreamableHttpMinimum; + return SupportedStreamableHttpVersions.Contains(version, StringComparer.Ordinal); } /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 52008f9..83dbeb3 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -301,6 +301,15 @@ public async ValueTask ConnectAndInitializeAsync(McpClient cli if (!ProtocolVersion.IsSupportedStreamableHttpVersion(result.ProtocolVersion)) { + try + { + await DisconnectAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Context.Logger.Warn($"[McpClient][Mcp] Failed to disconnect after receiving an unsupported protocol version. Error={ex.Message}"); + } + throw new McpClientException($"服务器返回了客户端不支持的协议版本:{result.ProtocolVersion}"); } diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs index 03699f5..ebe0f2e 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/HttpClientTransport.cs @@ -1,7 +1,12 @@ using System.Net.Http.Headers; +using System.Net; using System.Text; using System.Text.Json; +using DotNetCampus.ModelContextProtocol.CompilerServices; +using DotNetCampus.ModelContextProtocol.Exceptions; using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Protocol; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; namespace DotNetCampus.ModelContextProtocol.Transports.Http; @@ -20,6 +25,8 @@ public class HttpClientTransport : IClientTransport // 会话状态 private string? _sessionId; private string? _protocolVersion; + private InitializeRequestParams? _initializeRequestParams; + private readonly SemaphoreSlim _sessionRecoveryLock = new(1, 1); // 后台接收循环 (GET Loop) private Task? _receiveLoopTask; @@ -129,9 +136,14 @@ public async ValueTask SendMessageAsync(JsonRpcMessage message, CancellationToke } } - private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, CancellationToken cancellationToken) + private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, CancellationToken cancellationToken, bool allowSessionRecovery = true) { var isInitialize = message is JsonRpcRequest { Method: "initialize" }; + if (isInitialize && message is JsonRpcRequest initializeRequest) + { + CaptureInitializeRequestParams(initializeRequest); + } + var requestUrl = _options.ServerUrl; // 1. 构建请求 @@ -161,7 +173,19 @@ private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, Cancellatio _manager.LogRawOut("[Http]", $"POST, SessionId={_sessionId}", jsonContent); // 4. 发送请求 (ResponseHeadersRead 以支持流式响应) - var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + var requestSessionId = GetSingleHeaderValue(request.Headers, "Mcp-Session-Id"); + if (response.StatusCode == HttpStatusCode.NotFound && requestSessionId is not null) + { + if (allowSessionRecovery && await TryRecoverExpiredSessionAsync(requestSessionId, cancellationToken)) + { + await SendRequestCoreAsync(message, cancellationToken, allowSessionRecovery: false); + return; + } + + throw new McpClientException("HTTP session expired and could not be reinitialized."); + } // 5. 检查握手响应 (Initialize) 提取 SessionId if (isInitialize && response.Headers.TryGetValues("Mcp-Session-Id", out var headers)) @@ -171,9 +195,6 @@ private async ValueTask SendRequestCoreAsync(JsonRpcMessage message, Cancellatio { _sessionId = newId; _logger.Info($"[McpClient][Http] Session negotiated. SessionId={_sessionId}"); - - // 握手成功,启动后台接收循环 - StartReceiveLoop(); } } @@ -223,6 +244,11 @@ await ProcessSseStreamAsync(stream, cancellationToken, $"POST/sse, SessionId={_s await _manager.HandleRespondAsync(rpcResponse, cancellationToken); } } + + if (isInitialize && !string.IsNullOrEmpty(_sessionId)) + { + StartReceiveLoop(); + } } private string? TryExtractProtocolVersion(JsonElement resultElement, string source) @@ -247,6 +273,98 @@ await ProcessSseStreamAsync(stream, cancellationToken, $"POST/sse, SessionId={_s return null; } + private void CaptureInitializeRequestParams(JsonRpcRequest initializeRequest) + { + if (initializeRequest.Params is not { } paramsElement) + { + return; + } + + var requestParams = paramsElement.Deserialize(McpInternalJsonContext.Default.InitializeRequestParams); + if (requestParams is not null) + { + _initializeRequestParams = requestParams; + } + } + + private async Task TryRecoverExpiredSessionAsync(string expiredSessionId, CancellationToken cancellationToken) + { + var manager = _manager as ClientTransportManager; + if (manager is null || _initializeRequestParams is null) + { + InvalidateSessionState(); + return false; + } + + await _sessionRecoveryLock.WaitAsync(cancellationToken); + try + { + if (!string.IsNullOrEmpty(_sessionId) + && !string.Equals(_sessionId, expiredSessionId, StringComparison.Ordinal)) + { + return true; + } + + _logger.Warn($"[McpClient][Http] Session expired. SessionId={expiredSessionId}, reinitializing."); + InvalidateSessionState(); + + var initializeRequest = new JsonRpcRequest + { + Id = manager.MakeNewRequestId().ToJsonElement(), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToElement(_initializeRequestParams, McpInternalJsonContext.Default.InitializeRequestParams), + }; + + var response = await manager.SendRequestAsync(initializeRequest, cancellationToken).ConfigureAwait(false); + if (response.Error is not null) + { + throw new McpClientException($"Session reinitialization failed: {response.Error.Message}"); + } + + if (response.Result is not { } responseResult) + { + throw new McpClientException("Session reinitialization response is invalid."); + } + + var result = responseResult.Deserialize(McpInternalJsonContext.Default.InitializeResult) + ?? throw new McpClientException("Failed to deserialize session reinitialization response."); + + if (!ProtocolVersion.IsSupportedStreamableHttpVersion(result.ProtocolVersion)) + { + throw new McpClientException($"Server returned an unsupported protocol version during reinitialization: {result.ProtocolVersion}"); + } + + await manager.SendNotificationAsync(new JsonRpcNotification + { + Method = RequestMethods.NotificationsInitialized, + }, cancellationToken).ConfigureAwait(false); + + _logger.Info($"[McpClient][Http] Session reinitialized. SessionId={_sessionId}"); + return true; + } + finally + { + _sessionRecoveryLock.Release(); + } + } + + private void InvalidateSessionState() + { + StopReceiveLoop(); + _sessionId = null; + _protocolVersion = null; + } + + private static string? GetSingleHeaderValue(HttpHeaders headers, string headerName) + { + if (headers.TryGetValues(headerName, out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } + // --- 后台接收循环逻辑 (GET Loop) --- private void StartReceiveLoop() @@ -311,6 +429,17 @@ private async Task ReceiveLoopAsync(CancellationToken token) using (response) { + if (response.StatusCode == HttpStatusCode.NotFound && _sessionId is { } expiredSessionId) + { + if (await TryRecoverExpiredSessionAsync(expiredSessionId, token)) + { + break; + } + + _logger.Warn($"[McpClient][Http] Session expired during SSE polling and could not be reinitialized. SessionId={expiredSessionId}"); + break; + } + if (!response.IsSuccessStatusCode) { _logger.Warn($"[McpClient][Http] SSE received unexpected status code, retrying. StatusCode={response.StatusCode}"); diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs index 7046680..5bd9457 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs @@ -558,14 +558,20 @@ private async Task HandleRpcRequestAsync(HttpListenerContext context, string? se private async Task ValidateProtocolVersionHeaderAsync(HttpListenerContext context, string? protocolVersion, ProtocolVersion? negotiatedProtocolVersion) { - if (!string.IsNullOrEmpty(protocolVersion) && !ProtocolVersion.IsSupportedStreamableHttpVersion(protocolVersion)) + if (protocolVersion is not null && string.IsNullOrWhiteSpace(protocolVersion)) + { + await context.RespondHttpError(HttpStatusCode.BadRequest, "Invalid protocol version header."); + return false; + } + + if (protocolVersion is not null && !ProtocolVersion.IsSupportedStreamableHttpVersion(protocolVersion)) { await context.RespondHttpError(HttpStatusCode.BadRequest, - $"Unsupported protocol version. Supported range: {ProtocolVersion.StreamableHttpMinimum} to {ProtocolVersion.Current}"); + $"Unsupported protocol version. Supported versions: {ProtocolVersion.SupportedStreamableHttpVersionList}"); return false; } - if (!string.IsNullOrEmpty(protocolVersion) + if (protocolVersion is not null && negotiatedProtocolVersion is { } negotiated && !string.Equals(protocolVersion, negotiated.ToString(), StringComparison.Ordinal)) { diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/HttpClientNegotiationTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/HttpClientNegotiationTests.cs new file mode 100644 index 0000000..442af32 --- /dev/null +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Clients/HttpClientNegotiationTests.cs @@ -0,0 +1,359 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using DotNetCampus.ModelContextProtocol.Clients; +using DotNetCampus.ModelContextProtocol.Exceptions; +using DotNetCampus.ModelContextProtocol.Transports.Http; + +namespace DotNetCampus.ModelContextProtocol.Tests.Clients; + +[TestClass] +public class HttpClientNegotiationTests +{ + [TestMethod("HttpClient_FirstGetCarriesNegotiatedProtocolVersion: 初始化后的首个 GET 必须携带协商版本头")] + public async Task HttpClient_FirstGetCarriesNegotiatedProtocolVersion() + { + var handler = new RecordingMcpServerHandler(); + using var httpClient = new HttpClient(handler); + await using var client = new McpClientBuilder() + .WithClientInfo("test-client", "1.0.0") + .WithHttp(new HttpClientTransportOptions + { + ServerUrl = "http://localhost/mcp", + HttpClient = httpClient, + }) + .Build(); + + _ = await client.ListToolsAsync(); + + await handler.WaitForFirstGetAsync(); + + Assert.IsNotNull(handler.FirstGetProtocolVersion); + Assert.AreEqual("2025-06-18", handler.FirstGetProtocolVersion); + } + + [TestMethod("HttpClient_ReinitializesWhenSessionRequestReturns404: 带会话 ID 的请求收到 404 后应自动重建会话")] + public async Task HttpClient_ReinitializesWhenSessionRequestReturns404() + { + var handler = new SessionRecoveryHandler(); + using var httpClient = new HttpClient(handler); + await using var client = new McpClientBuilder() + .WithClientInfo("test-client", "1.0.0") + .WithHttp(new HttpClientTransportOptions + { + ServerUrl = "http://localhost/mcp", + HttpClient = httpClient, + }) + .Build(); + + var result = await client.ListToolsAsync(); + + Assert.AreEqual(0, result.Tools.Count); + Assert.AreEqual(2, handler.InitializeRequestCount); + Assert.IsFalse(handler.SecondInitializeHadSessionIdHeader); + } + + [TestMethod("HttpClient_RejectsUnknownFutureNegotiatedVersion: 客户端不应接受未知未来版本")] + public async Task HttpClient_RejectsUnknownFutureNegotiatedVersion() + { + var handler = new UnsupportedVersionHandler(); + using var httpClient = new HttpClient(handler); + await using var client = new McpClientBuilder() + .WithClientInfo("test-client", "1.0.0") + .WithHttp(new HttpClientTransportOptions + { + ServerUrl = "http://localhost/mcp", + HttpClient = httpClient, + }) + .Build(); + + var exception = await Assert.ThrowsExceptionAsync(() => client.ListToolsAsync()); + + StringAssert.Contains(exception.Message, "不支持的协议版本"); + Assert.IsTrue(handler.DeleteRequested); + Assert.IsTrue(handler.DeleteHadSessionIdHeader); + } + + private sealed class RecordingMcpServerHandler : HttpMessageHandler + { + private const string SessionId = "session-1"; + private readonly TaskCompletionSource _firstGetReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public string? FirstGetProtocolVersion { get; private set; } + + public async Task WaitForFirstGetAsync() + { + var completedTask = await Task.WhenAny(_firstGetReceived.Task, Task.Delay(TimeSpan.FromSeconds(2))); + if (completedTask != _firstGetReceived.Task) + { + Assert.Fail("未观察到初始化后的 GET 请求。"); + } + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get) + { + if (FirstGetProtocolVersion is null) + { + FirstGetProtocolVersion = GetSingleHeaderValue(request, "Mcp-Protocol-Version"); + _firstGetReceived.TrySetResult(); + } + + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed) + { + RequestMessage = request, + }; + } + + if (request.Method == HttpMethod.Delete) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + }; + } + + var requestContent = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + + if (requestContent.Contains("\"method\":\"initialize\"", StringComparison.Ordinal)) + { + var requestId = GetRequestIdLiteral(requestContent); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StreamContent(new DelayedReadMemoryStream( + Encoding.UTF8.GetBytes( + $"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"result\":{{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{{\"logging\":{{}}}},\"serverInfo\":{{\"name\":\"TestServer\",\"version\":\"1.0.0\"}}}}}}"), + 300)), + }; + response.Content.Headers.ContentType = new("application/json"); + response.Headers.TryAddWithoutValidation("Mcp-Session-Id", SessionId); + return response; + } + + if (requestContent.Contains("\"method\":\"notifications/initialized\"", StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.Accepted) + { + RequestMessage = request, + }; + } + + if (requestContent.Contains("\"method\":\"tools/list\"", StringComparison.Ordinal)) + { + var requestId = GetRequestIdLiteral(requestContent); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent( + $"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"result\":{{\"tools\":[]}}}}", + Encoding.UTF8, + "application/json"), + }; + return response; + } + + return new HttpResponseMessage(HttpStatusCode.Accepted) + { + RequestMessage = request, + }; + } + + private static string? GetSingleHeaderValue(HttpRequestMessage request, string headerName) + { + if (request.Headers.TryGetValues(headerName, out var values)) + { + return values.SingleOrDefault(); + } + + return null; + } + } + + private sealed class SessionRecoveryHandler : HttpMessageHandler + { + private int _initializeRequestCount; + + public int InitializeRequestCount => _initializeRequestCount; + + public bool SecondInitializeHadSessionIdHeader { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed) + { + RequestMessage = request, + }; + } + + if (request.Method == HttpMethod.Delete) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + }; + } + + var requestContent = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + + if (requestContent.Contains("\"method\":\"initialize\"", StringComparison.Ordinal)) + { + _initializeRequestCount++; + if (_initializeRequestCount == 2) + { + SecondInitializeHadSessionIdHeader = request.Headers.Contains("Mcp-Session-Id"); + } + + var requestId = GetRequestIdLiteral(requestContent); + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent( + $"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"result\":{{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{{\"logging\":{{}}}},\"serverInfo\":{{\"name\":\"TestServer\",\"version\":\"1.0.0\"}}}}}}", + Encoding.UTF8, + "application/json"), + }; + response.Headers.TryAddWithoutValidation("Mcp-Session-Id", _initializeRequestCount == 1 ? "session-1" : "session-2"); + return response; + } + + if (requestContent.Contains("\"method\":\"notifications/initialized\"", StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.Accepted) + { + RequestMessage = request, + }; + } + + if (requestContent.Contains("\"method\":\"tools/list\"", StringComparison.Ordinal)) + { + var sessionId = GetSingleHeaderValue(request, "Mcp-Session-Id"); + if (sessionId == "session-1") + { + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + RequestMessage = request, + }; + } + + var requestId = GetRequestIdLiteral(requestContent); + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent( + $"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"result\":{{\"tools\":[]}}}}", + Encoding.UTF8, + "application/json"), + }; + } + + return new HttpResponseMessage(HttpStatusCode.Accepted) + { + RequestMessage = request, + }; + } + } + + private sealed class UnsupportedVersionHandler : HttpMessageHandler + { + public bool DeleteRequested { get; private set; } + + public bool DeleteHadSessionIdHeader { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Delete) + { + DeleteRequested = true; + DeleteHadSessionIdHeader = request.Headers.Contains("Mcp-Session-Id"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + }; + } + + var requestContent = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + + if (requestContent.Contains("\"method\":\"initialize\"", StringComparison.Ordinal)) + { + var requestId = GetRequestIdLiteral(requestContent); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent( + $"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"result\":{{\"protocolVersion\":\"2026-01-01\",\"capabilities\":{{\"logging\":{{}}}},\"serverInfo\":{{\"name\":\"TestServer\",\"version\":\"1.0.0\"}}}}}}", + Encoding.UTF8, + "application/json"), + }; + response.Headers.TryAddWithoutValidation("Mcp-Session-Id", "session-1"); + return response; + } + + return new HttpResponseMessage(HttpStatusCode.Accepted) + { + RequestMessage = request, + }; + } + } + + private static string? GetSingleHeaderValue(HttpRequestMessage request, string headerName) + { + if (request.Headers.TryGetValues(headerName, out var values)) + { + return values.SingleOrDefault(); + } + + return null; + } + + private static string GetRequestIdLiteral(string requestContent) + { + using var document = JsonDocument.Parse(requestContent); + return document.RootElement.GetProperty("id").GetRawText(); + } + + private sealed class DelayedReadMemoryStream : MemoryStream + { + private readonly int _delayMilliseconds; + private bool _delayApplied; + + public DelayedReadMemoryStream(byte[] buffer, int delayMilliseconds) + : base(buffer) + { + _delayMilliseconds = delayMilliseconds; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await DelayOnceAsync(cancellationToken); + return await base.ReadAsync(buffer, cancellationToken); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await DelayOnceAsync(cancellationToken); + return await base.ReadAsync(buffer, offset, count, cancellationToken); + } + + private Task DelayOnceAsync(CancellationToken cancellationToken) + { + if (_delayApplied) + { + return Task.CompletedTask; + } + + _delayApplied = true; + return Task.Delay(_delayMilliseconds, cancellationToken); + } + } +} \ No newline at end of file diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs index 1453d5c..3a9fc3c 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using System.Text; using System.Text.Json; using DotNetCampus.ModelContextProtocol.Transports.Http; @@ -193,6 +194,46 @@ public async Task StreamableHttp_BatchRequest_IsExplicitlyRejected(HttpTransport Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); } + [TestMethod("StreamableHttp_UnknownFutureVersionNegotiatesDownToCurrent: 未知未来版本应回落到当前支持版本")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] + public async Task StreamableHttp_UnknownFutureVersionNegotiatesDownToCurrent(HttpTransportType type) + { + await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(type); + using var client = CreateHttpClient(); + + using var initializeRequest = CreateStreamableHttpRequest(HttpMethod.Post, package.Endpoint); + initializeRequest.Content = CreateInitializeRequestContent("2026-01-01"); + + using var initializeResponse = await client.SendAsync(initializeRequest); + + Assert.AreEqual(HttpStatusCode.OK, initializeResponse.StatusCode); + + using var document = JsonDocument.Parse(await initializeResponse.Content.ReadAsStringAsync()); + Assert.AreEqual("2025-11-25", document.RootElement.GetProperty("result").GetProperty("protocolVersion").GetString()); + } + + [TestMethod("StreamableHttp_BlankProtocolVersionHeaderIsRejected: 空白协议版本头应视为无效")] + [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + public async Task StreamableHttp_BlankProtocolVersionHeaderIsRejected(HttpTransportType type) + { + await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(type); + using var client = CreateHttpClient(); + + using var initializeRequest = CreateStreamableHttpRequest(HttpMethod.Post, package.Endpoint); + initializeRequest.Content = CreateInitializeRequestContent("2025-06-18"); + + using var initializeResponse = await client.SendAsync(initializeRequest); + + Assert.AreEqual(HttpStatusCode.OK, initializeResponse.StatusCode); + Assert.IsTrue(initializeResponse.Headers.TryGetValues("Mcp-Session-Id", out var sessionHeaders)); + var sessionId = sessionHeaders.Single(); + + var statusLine = await SendRawHttpRequestAsync(package.Endpoint, BuildBlankProtocolVersionRequest(package.Endpoint, sessionId)); + + StringAssert.Contains(statusLine, "400"); + } + private static HttpClient CreateHttpClient() { return new HttpClient @@ -234,6 +275,42 @@ private static Uri ResolveEndpoint(Uri baseEndpoint, string endpoint) : new Uri(baseEndpoint, endpoint); } + private static async Task SendRawHttpRequestAsync(Uri endpoint, string rawRequest) + { + using var tcpClient = new TcpClient(); + await tcpClient.ConnectAsync(endpoint.Host, endpoint.Port); + await using var stream = tcpClient.GetStream(); + + var requestBytes = Encoding.ASCII.GetBytes(rawRequest); + await stream.WriteAsync(requestBytes); + await stream.FlushAsync(); + + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + return await reader.ReadLineAsync() ?? string.Empty; + } + + private static string BuildBlankProtocolVersionRequest(Uri endpoint, string sessionId) + { + const string body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"; + var hostHeader = endpoint.IsDefaultPort ? endpoint.Host : $"{endpoint.Host}:{endpoint.Port}"; + var contentLength = Encoding.UTF8.GetByteCount(body); + + return string.Join("\r\n", + [ + $"POST {endpoint.PathAndQuery} HTTP/1.1", + $"Host: {hostHeader}", + "Accept: application/json", + "Accept: text/event-stream", + $"Mcp-Session-Id: {sessionId}", + "Mcp-Protocol-Version: ", + "Content-Type: application/json", + $"Content-Length: {contentLength}", + "Connection: close", + string.Empty, + body, + ]); + } + private static async Task ReadNextSseEventAsync(StreamReader reader, CancellationToken cancellationToken) { string? eventName = null; From dc0e53c43b89b991b5bee9961f90ea54704ff607 Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 21:37:14 +0800 Subject: [PATCH 48/77] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=B7=B2=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=E4=B8=A4=E4=B8=AA=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan.md | 102 -------------------- docs/plan0.md | 260 -------------------------------------------------- 2 files changed, 362 deletions(-) delete mode 100644 docs/plan.md delete mode 100644 docs/plan0.md diff --git a/docs/plan.md b/docs/plan.md deleted file mode 100644 index e886b89..0000000 --- a/docs/plan.md +++ /dev/null @@ -1,102 +0,0 @@ -# 协议兼容后续计划 - -本文只讨论 `plan0.md` 完成之后仍需推进的工作。当前后续开发的重点,已经不再是 2024-11-05 服务端传输层落地,而是把剩余版本的协商、投影和测试矩阵真正收拢成一套稳定方案。 - -后续如遇到任何传输层行为上的不确定性,应直接回查官方文档,而不是依赖历史印象或当前实现细节。 - -## 官方传输层文档 - -- 2025-11-25: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports -- 2025-06-18: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports -- 2025-03-26: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports -- 2024-11-05: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports - -> 人类注:请严格按官方文档执行。官方文档明确要求的(MUST)、强烈建议的(SHOULD)要严格执行;可能(MAY)会发生的,客户端应严格执行,服务端视情况决定;没有提到的则应优先让代码保持简洁和易于维护,不要过度保护过度投影。 - -## 目标 - -后续计划围绕三件事展开: - -1. 让 `2025-11-25`、`2025-06-18`、`2025-03-26` 在 `initialize` 之后形成明确、可追踪的版本协商结果,并贯穿整个会话。 -2. 继续保持内部以当前主版本模型处理协议消息,对外再按协商版本投影字段、能力和传输行为。 -3. 用清晰的测试矩阵定义“完全兼容、部分兼容、不支持”,避免文档声明与实际行为脱节。 - -这里的边界也需要提前说明:`2024-11-05` 目前以服务端兼容基线存在,后续只做回归保持,不再把它作为本计划的开发主线;客户端传输层也不再新增对 `2024-11-05` HTTP+SSE 的支持。 - -## 设计方向 - -后续实现仍应坚持“最新内核,边界投影”的路线。 - -也就是说,内部协议处理继续以 `2025-11-25` 的消息模型和处理流程为主,不为每个版本拆出一套业务逻辑;真正按版本变化的部分,集中放在以下三层: - -1. `initialize` 阶段的版本选择。 -2. 传输层会话上的协商状态。 -3. 出入站消息在边界处的归一化与投影。 - -这样可以把版本差异限制在协议边界,而不把工具、资源和请求处理主流程改成到处都是条件分支。 - -## 后续工作 - -### 第一阶段:把协商状态做完整 - -先把“协商出的版本究竟是什么”这件事彻底做实。 - -这一阶段应完成: - -1. 建立统一的协议版本支持矩阵,明确服务端和客户端各自支持哪些版本。 -2. 在 `initialize` 阶段选择最终版本,而不是继续固定返回当前版本。 -3. 把协商结果写入会话或传输状态,让后续请求始终依据同一份结果运行。 -4. 让后续 HTTP 请求对 `MCP-Protocol-Version` 的校验与协商结果保持一致。 - -这一阶段完成后,版本协商就不再只是字符串存在于消息里,而会成为后续所有请求都能依赖的正式状态。 - -### 第二阶段:补齐 Streamable HTTP 家族差异 - -接下来要解决的是 `2025-03-26`、`2025-06-18`、`2025-11-25` 之间仍然存在的传输与消息差异。 - -这一阶段应完成: - -1. 补齐 `2025-03-26` 的 batch 处理,至少明确服务端与客户端对 batch 的支持边界。 -2. 根据协商结果裁剪 `InitializeResult` 和后续运行期消息中的能力与字段。 -3. 统一 GET、POST、DELETE、SSE 这些 Streamable HTTP 行为在不同版本下的校验规则。 - -这里最重要的约束是:如果某个版本只支持到“单消息子集”而尚未补齐 batch 或某些传输语义,就必须在测试和文档里明确写清楚,而不是笼统声称已支持整个版本。 - -### 第三阶段:收敛客户端策略 - -客户端后续工作只围绕 Streamable HTTP 家族展开,不再扩展 `2024-11-05` 的 legacy transport。 - -这一阶段应完成: - -1. 在 `HttpClientTransport` 上引入“支持版本集合”和“首选版本”的正式配置。 -2. 默认优先最新版本,在服务端返回较低但仍受支持的版本时能够稳定收敛。 -3. 明确客户端对 `2025-03-26`、`2025-06-18`、`2025-11-25` 的实际支持范围。 - -这样后续无论是显式配置兼容版本,还是默认走最新版本,客户端行为都会更清楚,也更容易测试。 - -### 第四阶段:收口测试与文档 - -最后一阶段不是再扩展新能力,而是把已经实现的兼容范围真正固定下来。 - -这一阶段应完成: - -1. 建立覆盖版本协商、消息投影、Streamable HTTP 差异和回归路径的测试矩阵。 -2. 保持 `2024-11-05` 服务端兼容的回归测试,但不再让它继续牵引新的客户端设计。 -3. 在 README 和知识文档中明确列出各版本的支持状态与限制。 - -到这一阶段,项目对外说明的版本兼容能力应当由测试结果直接支撑,而不是由计划文本推断。 - -## 验收口径 - -本计划完成时,应该能够明确回答下面几件事: - -1. 服务端和客户端分别支持哪些协议版本。 -2. `initialize` 之后协商出的版本如何被保存和持续使用。 -3. `2025-03-26` 的 batch、`2025-06-18` 与 `2025-11-25` 的字段差异是否已经补齐,还是仍处于部分兼容。 -4. `2024-11-05` 的服务端兼容是否持续可用。 - -如果这四件事仍然需要靠阅读实现代码来猜,那么计划对应的工作就还没有真正完成。 - -## 一句话结论 - -接下来的开发重点,不再是继续铺开新的 legacy 传输实现,而是把 `2025-03-26`、`2025-06-18`、`2025-11-25` 这一组 Streamable HTTP 版本的协商、投影和测试边界做扎实,同时把 `2024-11-05` 维持在稳定可回归的服务端兼容基线上。 diff --git a/docs/plan0.md b/docs/plan0.md deleted file mode 100644 index bc86d43..0000000 --- a/docs/plan0.md +++ /dev/null @@ -1,260 +0,0 @@ -# 2024-11-05 服务端兼容计划 - -本文只规划一件事:让本库的两个 HTTP 服务端传输层兼容 MCP 2024-11-05 的 HTTP with SSE。 - -本期目标限定在: - -1. `LocalHostHttpServerTransport` 支持 2024-11-05。 -2. `TouchSocketHttpServerTransport` 支持 2024-11-05。 -3. 兼容代码尽量独立,避免破坏现有新协议实现。 -4. 新协议热路径的性能与行为保持稳定。 - -不在本文范围内的内容不再展开,包括客户端传输层、其他协议版本的统一兼容架构,以及更大范围的版本协商设计。那部分长期方案继续放在 `未来plan.md`。 - -## 协议边界 - -2024-11-05 的 HTTP with SSE 有几条直接影响实现的约束: - -1. 服务端需要两个端点:一个 SSE 端点,一个普通 HTTP POST 端点。 -2. 客户端连上 SSE 端点后,服务端必须先发送 `endpoint` 事件。 -3. `endpoint` 事件里要告诉客户端后续 POST 的目标地址。 -4. 服务端发往客户端的消息通过 SSE `message` 事件发送,事件数据是 JSON-RPC 消息。 -5. 服务端仍然需要做 `Origin` 校验。 -6. `initialize` 返回的 `protocolVersion` 必须是 `2024-11-05`。 - -除此之外,本文采取“最小兼容”原则:凡是规范没有明确要求必须裁剪的内容,不预先做过度保护;如果后续联调用例证明旧客户端无法接受,再追加有针对性的适配。 - -## 设计原则 - -### 1. 旧协议逻辑独立放置 - -旧协议兼容尽量拆到单独文件,而不是直接揉进现有核心流程。优先考虑在基础库里新增一组共享类型,由 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 只做轻量接入。 - -如果某段逻辑必须留在原类中,也应拆成单独的 `#region Legacy SSE (2024-11-05)`,避免和现有 Streamable HTTP 路径交错。 - -### 2. 新协议热路径不受影响 - -`/mcp` 对应的现有 Streamable HTTP 流程继续保持原样。旧协议逻辑只在命中 `/sse` 与 `/messages` 这两个 legacy 端点时才进入。 - -目标是让: - -1. 新协议请求不创建 legacy 对象。 -2. 新协议请求不进入 legacy 判断链的深层逻辑。 -3. 开启旧协议兼容后,新协议的可观测行为不变。 - -### 3. 两个服务端共用一套旧协议核心 - -`LocalHost` 和 `TouchSocket` 的底层 HTTP API 不同,但 2024-11-05 的协议规则是相同的。旧协议的 session 管理、SSE 事件格式、消息桥接、初始化适配,应该尽量共用一套实现。 - -这样可以把差异尽量收敛到“如何读请求、如何写响应、如何保持 SSE 连接”这层适配,而不是把同一份兼容逻辑复制两遍。 - -### 4. 先满足规范硬约束,再看互操作性补丁 - -本期必须先满足的是旧协议传输形态和 `protocolVersion` 返回值。至于 `serverInfo`、`capabilities` 是否需要额外裁剪,先不要在计划里预设太多规则。 - -建议的顺序是: - -1. 先让旧客户端按 2024-11-05 的方式成功连上并完成 `initialize`。 -2. 默认尽量复用当前消息模型。 -3. 如果旧客户端对新增字段、能力或消息方法存在兼容问题,再做最小范围的定点适配。 - -## 建议结构 - -建议在基础库中增加一组共享的 legacy 组件,例如: - -1. `LegacySseSession` -2. `LegacySseEventWriter` -3. `LegacySseRequestRouter` -4. `LegacyInitializeResponseAdapter` -5. `LegacySseEndpointInfo` - -可以放在如下位置: - -1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/` -2. 或 `src/DotNetCampus.ModelContextProtocol/Transports/LegacyHttpSse/` - -两个服务端传输层只保留薄适配: - -1. 路由识别 `/sse` 与 `/messages` -2. 创建/查找 legacy session -3. 把请求对象转给共享核心 -4. 把共享核心输出写回各自的 HTTP/SSE API - -如果后续证明这套共享抽象不够顺手,再退一步,把每个传输层中的旧协议部分拆成单独区域,但仍然保持独立方法和独立文件,不直接污染现有 `/mcp` 主流程。 - -## 兼容入口设计 - -两个服务端都需要支持如下 legacy 端点: - -1. `GET {EndPoint}/sse` -2. `POST {EndPoint}/messages` - -其中: - -1. `GET /sse` 负责建立 SSE 连接并发送 `endpoint` 事件。 -2. `POST /messages` 负责接收客户端后续发来的 JSON-RPC 消息。 - -旧协议的连接时序建议统一为: - -1. 客户端请求 `GET /sse`。 -2. 服务端创建 legacy session。 -3. 服务端返回 `text/event-stream`。 -4. 服务端立即发送 `event: endpoint`。 -5. `data` 中带上当前 session 的消息提交地址。 -6. 客户端之后持续向 `/messages` 发 POST。 -7. 服务端产生的 JSON-RPC 响应和服务端主动消息,都通过 SSE `message` 事件返回。 - -这里要注意,2024-11-05 不是当前 `/mcp` 的变体,而是另一套传输形态。因此不要把现有 `application/json` 或 `text/event-stream` 的 `/mcp` 响应策略直接套到 `/messages` 上。 - -## Session 设计 - -建议为旧协议使用独立 session 类型,不直接复用现有 `HttpServerTransportSession`。 - -这个 session 至少需要承担: - -1. 维护 `sessionId` -2. 保存 SSE 输出目标 -3. 发送 `endpoint` 事件 -4. 发送 `message` 事件 -5. 感知连接断开并做清理 -6. 把服务端回包与主动消息统一投递到 SSE 通道 - -这样做的好处是: - -1. 旧协议的事件格式不会污染现有 Streamable HTTP session。 -2. `LocalHost` 和 `TouchSocket` 都能围绕同一个 legacy session 抽象做适配。 -3. 后续若要补更多 2024-11-05 细节,也不会牵动 `/mcp` 主流程。 - -## initialize 兼容策略 - -本期对 `initialize` 的处理采用“硬要求最少化、适配后置化”的策略。 - -必须落实的内容: - -1. legacy 路径收到 `initialize` 后,返回结果中的 `protocolVersion` 必须是 `2024-11-05`。 -2. legacy 路径下的请求与响应都走旧协议通道,不混用当前 `/mcp` 的头部和会话规则。 - -初版不必预先做大量字段裁剪。建议先按以下方式处理: - -1. 默认复用当前 `InitializeResult` 的主体生成逻辑。 -2. 在 legacy 路径上仅强制改写 `protocolVersion`。 -3. 其余字段保持现状,除非: - - 规范明确要求不能这样做 - - 旧客户端联调时确实失败 - -如果后续验证发现某些旧客户端无法接受新增字段,再新增一个轻量的 `LegacyInitializeResponseAdapter`,专门做定点裁剪,而不是一开始就铺开一整套通用投影框架。 - -## 开关与默认值 - -当前 `LocalHostHttpServerTransportOptions.IsCompatibleWithSse` 默认为 `false`。这一点不必在计划阶段先写死最终结论,但建议按下面的顺序推进: - -1. 先让两套服务端都具备旧协议能力。 -2. 让旧协议代码结构上与新协议热路径隔离,做到不开启时几乎无额外代价。 -3. 在兼容模式关闭时,如果命中了明显的旧协议访问特征,就返回更清晰的错误信息,提示开发者开启兼容模式。 -4. 等实现完成并通过回归与性能验证后,再决定默认值是否要调整为 `true`。 - -TouchSocket 侧也建议补一个对称的开关配置,而不是把兼容逻辑写成始终开启但不可控的状态。 - -## 性能要求 - -本期兼容旧协议时,性能目标应明确为: - -1. 使用新协议连接时,不引入可观测的性能退化。 -2. 使用旧协议连接时,可以接受适度损耗,但不要出现明显的额外对象堆积和不必要复制。 -3. 两套传输层都尽量复用现有 JSON-RPC 读写与应用层桥接能力。 - -实现上建议注意: - -1. legacy 端点判断尽量前置且浅层。 -2. 只有命中 legacy 路径时才创建 legacy session 与 SSE writer。 -3. 不要让新协议请求进入 legacy 的复杂分支。 - -## 分步实施 - -### 第一步:补齐共享 legacy 核心 - -1. 新增 legacy session、event writer、endpoint builder、请求分发等共享类型。 -2. 明确 LocalHost 与 TouchSocket 各自需要实现的薄适配接口。 - -完成标志: - -1. 共享核心不依赖具体 HTTP 实现。 -2. 两个传输层都能接入这套核心。 - -### 第二步:接入 LocalHost - -1. 为 `LocalHostHttpServerTransport` 增加 `/sse` 与 `/messages` 路由。 -2. 接入 legacy session 生命周期管理。 -3. 让旧协议响应通过 SSE `message` 事件发送。 - -完成标志: - -1. `LocalHost` 能完成 `GET /sse` 建链。 -2. `endpoint` 事件格式正确。 -3. `initialize` 与至少一条普通请求能走通。 - -### 第三步:接入 TouchSocket - -1. 让 `TouchSocketHttpServerTransport` 对称支持 `/sse` 与 `/messages`。 -2. 接入同一套 legacy 核心。 -3. 补齐 TouchSocket 对应的配置开关和错误提示。 - -完成标志: - -1. `TouchSocket` 的旧协议行为与 `LocalHost` 对齐。 -2. 两个服务端对旧协议返回一致的传输语义。 - -### 第四步:联调与定点适配 - -1. 用旧客户端验证 `initialize`、普通请求、服务端回包。 -2. 若发现旧客户端对新增字段或消息不兼容,再追加定点裁剪。 -3. 评估兼容开关默认值与错误提示策略。 - -完成标志: - -1. 旧客户端能连通两个服务端。 -2. 当前新协议路径回归通过。 -3. 若有必要的裁剪,范围被限制在 legacy 适配层中。 - -## 建议改动位置 - -建议优先落在以下文件或相邻新文件中: - -1. `src/DotNetCampus.ModelContextProtocol/Transports/Http/Legacy/**` -2. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransport.cs` -3. `src/DotNetCampus.ModelContextProtocol/Transports/Http/LocalHostHttpServerTransportOptions.cs` -4. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransport.cs` -5. `src/DotNetCampus.ModelContextProtocol.TouchSocket.Http/Transports/TouchSocket/TouchSocketHttpServerTransportOptions.cs` -6. `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` - -其中,`McpServerRequestHandlers` 只在 legacy `initialize` 的必要适配点上做改动,不建议把大段旧协议逻辑挪进请求处理主流程。 - -## 测试计划 - -测试应直接围绕两个服务端传输层展开,避免再扩散到整套多版本兼容话题。 - -优先补在现有 HTTP 测试体系中,并复用已经存在的 `HttpTransportType.LocalHost` / `HttpTransportType.TouchSocket` 双通道测试模式。 - -至少覆盖以下用例: - -1. `GET /sse` 成功建立连接,并首先收到 `endpoint` 事件。 -2. `POST /messages` 可以完成 `initialize`。 -3. `initialize` 返回的 `protocolVersion` 为 `2024-11-05`。 -4. 普通工具调用的响应能够通过 SSE `message` 事件送达。 -5. 兼容开关关闭时,访问旧协议端点能得到清晰错误。 -6. 新协议 `/mcp` 的现有行为在两种服务端上都不回退。 -7. 如果后续追加字段裁剪,对应增加回归测试,防止适配范围继续膨胀。 - -## 验收标准 - -本期完成后,应达到: - -1. 旧客户端可以连接 `LocalHostHttpServerTransport`。 -2. 旧客户端可以连接 `TouchSocketHttpServerTransport`。 -3. 两个服务端都能通过 `/sse` + `/messages` 完成 `initialize` 和至少一条普通请求。 -4. 新协议 `/mcp` 现有能力和性能不出现明显回退。 -5. 旧协议兼容代码主要集中在独立文件或清晰区域内,没有大面积污染现有核心实现。 - -## 一句话结论 - -当前阶段最合适的做法,是为 `LocalHostHttpServerTransport` 和 `TouchSocketHttpServerTransport` 增加一套共享的 2024-11-05 legacy 适配层:旧协议逻辑独立放置,两个传输层只做薄接入,`initialize` 先满足硬约束,其余兼容行为按规范和联调结果做最小增量适配。 \ No newline at end of file From ec9bf9e42fb492891b0d0a5159cb0cbd19c80a43 Mon Sep 17 00:00:00 2001 From: walterlv Date: Mon, 27 Apr 2026 22:15:14 +0800 Subject: [PATCH 49/77] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transports/HttpTransportTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs index 3a9fc3c..02e0718 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/HttpTransportTests.cs @@ -215,6 +215,8 @@ public async Task StreamableHttp_UnknownFutureVersionNegotiatesDownToCurrent(Htt [TestMethod("StreamableHttp_BlankProtocolVersionHeaderIsRejected: 空白协议版本头应视为无效")] [DataRow(HttpTransportType.LocalHost, DisplayName = "LocalHost")] + // TODO:等待 TouchSocket 修复客户端发送空白 Header 时,HttpBase 内部直接丢弃此头的问题后,接触注释本测试。 + // [DataRow(HttpTransportType.TouchSocket, DisplayName = "TouchSocket")] public async Task StreamableHttp_BlankProtocolVersionHeaderIsRejected(HttpTransportType type) { await using var package = await TestMcpFactory.Shared.CreateSimpleHttpAsync(type); From a5e98fe083a7eb10f4ca1e6a9ab002760fdfa67f Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 30 Apr 2026 09:40:44 +0800 Subject: [PATCH 50/77] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/knowledge/http-server-transport-implementation-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/knowledge/http-server-transport-implementation-guide.md b/docs/knowledge/http-server-transport-implementation-guide.md index 97453ea..a99d2e6 100644 --- a/docs/knowledge/http-server-transport-implementation-guide.md +++ b/docs/knowledge/http-server-transport-implementation-guide.md @@ -119,6 +119,7 @@ * **插件机制**:继承 `HttpPluginBase`。 * **请求拦截**:在 `OnHttpRequest` 中判断 `e.Context.Request.Url` 是否匹配。 * **SSE 支持**:需要确保 TouchSocket 支持类似 `Chunked` 传输或长连接保持。通常需要将处理模式设置为不要立即关闭连接,并持续向 `HttpResponse` 写入数据。 +* **实现限制与调试入口**:TouchSocket 的插件层看到的是“已经解析完成”的 `HttpContext`,而不是原始字节流。涉及空白 header、重复 header、SSE 生命周期等问题时,需等待上游合并 [Discussion: Preserving Empty HTTP Header Values vs. Dropping Them](https://github.com/RRQM/TouchSocket/pull/133)。 ## 4. 关键数据结构:Session Store From ac1e063d55c7c4450248d9ca364aa9e22d1739f2 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 30 Apr 2026 10:29:08 +0800 Subject: [PATCH 51/77] =?UTF-8?q?=E5=8A=A0=E5=85=A5=20In-Process=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/knowledge/testing-guide.md | 14 +- docs/knowledge/transport-layer-design.md | 23 +++ .../Clients/McpClientBuilder.cs | 11 ++ .../Servers/McpServerBuilder.cs | 35 ++++ .../Transports/ClientTransportManager.cs | 14 ++ .../InProcess/InProcessClientTransport.cs | 177 +++++++++++++++++ .../InProcess/InProcessServerTransport.cs | 183 +++++++++++++++++ .../InProcessServerTransportSession.cs | 63 ++++++ .../InProcess/InProcessTransportOptions.cs | 12 ++ .../InProcess/InProcessTransportPair.cs | 131 +++++++++++++ .../TestMcpFactory.cs | 55 ++++++ .../Transports/InProcessTransportTests.cs | 185 ++++++++++++++++++ .../Transports/StdioTransportTests.cs | 33 ++-- 13 files changed, 912 insertions(+), 24 deletions(-) create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessClientTransport.cs create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransport.cs create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransportSession.cs create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportOptions.cs create mode 100644 src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportPair.cs create mode 100644 tests/DotNetCampus.ModelContextProtocol.Tests/Transports/InProcessTransportTests.cs diff --git a/docs/knowledge/testing-guide.md b/docs/knowledge/testing-guide.md index edd62ff..2c6b9d1 100644 --- a/docs/knowledge/testing-guide.md +++ b/docs/knowledge/testing-guide.md @@ -89,15 +89,15 @@ public async Task ListTools(HttpTransportType transportType) ### 3.2 传输层模拟方案 #### 内存传输 (In-Process Transport) -实现 `InProcessServerTransport` 和 `InProcessClientTransport`。 -* 利用字符串 Key 或共享内存对象进行配对。 -* **优势**: 极快,无网络/IO开销,适合大量逻辑测试。 -* **未来**: 成熟后的代码可移入主库供用户使用。 +当前主库已提供 `InProcessTransportPair`、`InProcessServerTransport` 和 `InProcessClientTransport`。 +* 使用 `McpServerBuilder.WithInProcess(out var pair)` 与 `McpClientBuilder.WithInProcess(pair)` 成对创建同进程连接。 +* 传输层内部以 JSON 文本作为消息信封,既避免网络/IO开销,又保留 JSON-RPC 序列化边界。 +* **优势**: 极快,无端口占用,无外部进程,适合大量 Client + Server 端到端逻辑测试。 #### Stdio 传输模拟 改造现有的 `StdioServerTransport` 和 `StdioClientTransport`。 * **构造函数注入**: 允许传入 `Stream` (StandardInput/StandardOutput) 而非硬编码 Console。 -* **测试方式**: 在测试中使用 `MemoryStream` 或 `PipeStream` 连接 Server 和 Client 实例,无需启动外部子进程即可测试流式协议逻辑(如 Header 解析、粘包处理)。 +* **测试方式**: 在测试中使用 `MemoryStream` 或 `PipeStream` 连接 Server 和 Client 实例,无需启动外部子进程即可测试 stdio 的换行分隔 JSON-RPC 消息边界。 ### 3.3 官方 Server 启动器 (`OfficialServerFixture`) 编写一个帮助类,用于启动外部 Node.js 进程运行官方示例 Server。 @@ -118,10 +118,10 @@ public async Task ListTools(HttpTransportType transportType) ### Phase 2: 传输层增强 (Priority Medium) - [ ] **TestInfrastructure**: - - [ ] 实现 `InProcessTransport` 并验证其可靠性。 + - [x] 实现 In-Process 传输层并验证其可靠性。 - [ ] 改造 Stdio Transport 支持 Stream 注入。 - [ ] **Transport/StdioTransportTests.cs**: - - [ ] 使用内存流模拟 Stdio,验证 `StreamJsonRpc` 或自定义流读写的粘包/分片处理能力。 + - [ ] 使用内存流模拟 Stdio,验证换行分隔 JSON-RPC 消息的读取、连续消息和非法消息处理能力。 ### Phase 3: 官方兼容性 (Priority Low/Validation) - [ ] **Compliance/OfficialIntegrationTests.cs**: diff --git a/docs/knowledge/transport-layer-design.md b/docs/knowledge/transport-layer-design.md index 8466192..fbf99a8 100644 --- a/docs/knowledge/transport-layer-design.md +++ b/docs/knowledge/transport-layer-design.md @@ -2,6 +2,29 @@ > 本文档描述 DotNetCampus.ModelContextProtocol 库的传输层抽象设计,支持 stdio、HTTP (Streamable HTTP + SSE)、InProcess 和 IPC 等多种传输协议。 +## 当前实现备注 + +当前主库的传输层抽象以 `IClientTransport`、`IServerTransport`、`IClientTransportManager`、`IServerTransportManager` 和 `ServerTransportSession` 为准。In-Process 传输层已采用一对一的 `InProcessTransportPair` 连接对实现,内部通过两条内存队列传递完整 JSON-RPC 文本消息。 + +使用示例: + +```csharp +var server = new McpServerBuilder("TestMcpServer", "1.0.0") + .WithInProcess(out var transportPair) + .WithTools(t => t.WithTool(() => new SimpleTool())) + .Build(); + +await server.StartAsync(); + +var client = new McpClientBuilder() + .WithInProcess(transportPair) + .Build(); + +var tools = await client.ListToolsAsync(); +``` + +本文后续部分包含较早期的架构草案和示例;如与当前代码不一致,以本节和源码为准。 + ## 📋 目录 - [设计目标](#设计目标) diff --git a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs index dc7293d..27e03b9 100644 --- a/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Clients/McpClientBuilder.cs @@ -2,6 +2,7 @@ using DotNetCampus.ModelContextProtocol.Protocol.Messages; using DotNetCampus.ModelContextProtocol.Transports; using DotNetCampus.ModelContextProtocol.Transports.Http; +using DotNetCampus.ModelContextProtocol.Transports.InProcess; using DotNetCampus.ModelContextProtocol.Transports.Stdio; using DotNetCampus.ModelContextProtocol.Utils; @@ -96,6 +97,16 @@ public McpClientBuilder WithStdio(StdioClientTransportOptions options) return WithTransport(m => new StdioClientTransport(m, options)); } + /// + /// 使用 In-Process 传输层连接到同进程内的 MCP 服务器。 + /// + /// In-Process 传输层连接对。 + /// 用于链式调用的 MCP 客户端生成器。 + public McpClientBuilder WithInProcess(InProcessTransportPair transportPair) + { + return WithTransport(m => new InProcessClientTransport(m, transportPair)); + } + /// /// 使用 Streamable HTTP 传输层连接到 MCP 服务器。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs index 02f1c18..035754c 100644 --- a/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs +++ b/src/DotNetCampus.ModelContextProtocol/Servers/McpServerBuilder.cs @@ -5,6 +5,7 @@ using DotNetCampus.ModelContextProtocol.Hosting.Logging; using DotNetCampus.ModelContextProtocol.Transports; using DotNetCampus.ModelContextProtocol.Transports.Http; +using DotNetCampus.ModelContextProtocol.Transports.InProcess; using DotNetCampus.ModelContextProtocol.Transports.Stdio; using DotNetCampus.ModelContextProtocol.Utils; @@ -37,6 +38,40 @@ public McpServerBuilder WithStdio() return this; } + /// + /// 允许此 MCP 服务器通过 In-Process 传输层在同进程内提供服务。 + /// + /// In-Process 传输层连接对。 + /// 用于链式调用的 MCP 服务器生成器。 + public McpServerBuilder WithInProcess(InProcessTransportPair transportPair) + { + _transportFactories.Add(m => new InProcessServerTransport(m, transportPair)); + return this; + } + + /// + /// 创建 In-Process 传输层连接对,并允许此 MCP 服务器通过该连接对在同进程内提供服务。 + /// + /// 创建出的 In-Process 传输层连接对。 + /// 用于链式调用的 MCP 服务器生成器。 + public McpServerBuilder WithInProcess(out InProcessTransportPair transportPair) + { + transportPair = new InProcessTransportPair(); + return WithInProcess(transportPair); + } + + /// + /// 创建 In-Process 传输层连接对,并允许此 MCP 服务器通过该连接对在同进程内提供服务。 + /// + /// In-Process 传输层选项。 + /// 创建出的 In-Process 传输层连接对。 + /// 用于链式调用的 MCP 服务器生成器。 + public McpServerBuilder WithInProcess(InProcessTransportOptions options, out InProcessTransportPair transportPair) + { + transportPair = new InProcessTransportPair(options); + return WithInProcess(transportPair); + } + /// /// 允许此 MCP 服务器通过 HTTP 提供服务。 /// diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs index 83dbeb3..25f2fa1 100644 --- a/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs +++ b/src/DotNetCampus.ModelContextProtocol/Transports/ClientTransportManager.cs @@ -45,6 +45,20 @@ internal void SetSamplingHandler(Func + /// 取消所有正在等待响应的客户端请求。 + /// + internal void CancelAllPendingRequests() + { + foreach (var pendingRequest in _pendingRequests) + { + if (_pendingRequests.TryRemove(pendingRequest.Key, out var taskCompletionSource)) + { + taskCompletionSource.TrySetCanceled(); + } + } + } + /// public RequestId MakeNewRequestId() { diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessClientTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessClientTransport.cs new file mode 100644 index 0000000..77375b0 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessClientTransport.cs @@ -0,0 +1,177 @@ +using System.Threading.Channels; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; + +namespace DotNetCampus.ModelContextProtocol.Transports.InProcess; + +/// +/// In-Process 客户端传输层,用于同进程内的 MCP 通信。 +/// +public sealed class InProcessClientTransport : IClientTransport +{ + private readonly IClientTransportManager _manager; + private readonly InProcessTransportPair _transportPair; + private CancellationTokenSource? _disconnectCancellationTokenSource; + private Task? _runLoopTask; + private int _connected; + + /// + /// 初始化 类的新实例。 + /// + /// 辅助管理 MCP 传输层的管理器。 + /// In-Process 传输层连接对。 + public InProcessClientTransport(IClientTransportManager manager, InProcessTransportPair transportPair) + { + _manager = manager; + _transportPair = transportPair; + _transportPair.AttachClient(); + } + + private IMcpLogger Log => _manager.Context.Logger; + + /// + public async ValueTask ConnectAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.CompareExchange(ref _connected, 1, 0) != 0) + { + return; + } + + try + { + Log.Info($"[McpClient][InProcess] Transport started."); + + await _transportPair.WaitForServerStartedAsync(cancellationToken).ConfigureAwait(false); + _disconnectCancellationTokenSource = new CancellationTokenSource(); + _runLoopTask = RunLoopAsync(_disconnectCancellationTokenSource.Token); + } + catch + { + Interlocked.Exchange(ref _connected, 0); + throw; + } + } + + /// + public async ValueTask DisconnectAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _connected, 0) == 0) + { + return; + } + + _transportPair.CompleteClient(); + + var cancellationTokenSource = _disconnectCancellationTokenSource; + if (cancellationTokenSource is not null) + { + cancellationTokenSource.Cancel(); + } + + CancelAllPendingRequests(); + + if (_runLoopTask is not null) + { + try + { + await _runLoopTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + } + catch (ChannelClosedException) + { + } + } + + cancellationTokenSource?.Dispose(); + _disconnectCancellationTokenSource = null; + _runLoopTask = null; + } + + /// + public async ValueTask SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) + { + if (Volatile.Read(ref _connected) == 0) + { + throw new InvalidOperationException("In-Process 传输层尚未连接或已经断开,无法发送消息。"); + } + + var line = _manager.WriteMessageAsync(message); + _manager.LogRawOut("[InProcess]", line); + await _transportPair.SendToServerAsync(line, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask DisposeAsync() + { + await DisconnectAsync().ConfigureAwait(false); + } + + private async Task RunLoopAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var line in _transportPair.ReadServerMessagesAsync(cancellationToken).ConfigureAwait(false)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + _manager.LogRawIn("[InProcess]", line); + + JsonRpcMessage? message; + try + { + message = await _manager.ReadMessageAsync(line).ConfigureAwait(false); + } + catch + { + Log.Warn($"[McpClient][InProcess] Invalid server message received."); + continue; + } + + switch (message) + { + case JsonRpcRequest request: + await _manager.HandleServerRequestAsync(request, cancellationToken).ConfigureAwait(false); + break; + case JsonRpcResponse response: + await _manager.HandleRespondAsync(response, cancellationToken).ConfigureAwait(false); + break; + default: + Log.Warn($"[McpClient][InProcess] Unrecognized server message received."); + break; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (ChannelClosedException) + { + } + catch (Exception ex) + { + Log.Error($"[McpClient][InProcess] Error in transport loop.", ex); + } + finally + { + Interlocked.Exchange(ref _connected, 0); + CancelAllPendingRequests(); + } + } + + private void CancelAllPendingRequests() + { + if (_manager is ClientTransportManager manager) + { + manager.CancelAllPendingRequests(); + } + } +} \ No newline at end of file diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransport.cs b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransport.cs new file mode 100644 index 0000000..d301e27 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransport.cs @@ -0,0 +1,183 @@ +using System.Threading.Channels; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Hosting.Services; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; + +namespace DotNetCampus.ModelContextProtocol.Transports.InProcess; + +/// +/// In-Process 服务端传输层,用于同进程内的 MCP 通信。 +/// +public sealed class InProcessServerTransport : IServerTransport +{ + private readonly IServerTransportManager _manager; + private readonly InProcessTransportPair _transportPair; + private readonly InProcessServerTransportSession _session; + private int _started; + + /// + /// 初始化 类的新实例。 + /// + /// 辅助管理 MCP 传输层的管理器。 + /// In-Process 传输层连接对。 + public InProcessServerTransport(IServerTransportManager manager, InProcessTransportPair transportPair) + { + _manager = manager; + _transportPair = transportPair; + _transportPair.AttachServer(); + _session = new InProcessServerTransportSession(manager, transportPair); + } + + private IMcpLogger Log => _manager.Context.Logger; + + /// + public Task StartAsync(CancellationToken startingCancellationToken, CancellationToken runningCancellationToken) + { + if (Interlocked.CompareExchange(ref _started, 1, 0) != 0) + { + return Task.FromResult(Task.CompletedTask); + } + + Log.Info($"[McpServer][InProcess] Transport started."); + + _manager.Add(_session); + _transportPair.MarkServerStarted(); + return Task.FromResult(RunLoopAsync(runningCancellationToken)); + } + + /// + public async ValueTask DisposeAsync() + { + Log.Info($"[McpServer][InProcess] Disposing transport."); + + _transportPair.CompleteServer(); + await _session.DisposeAsync().ConfigureAwait(false); + } + + private async Task RunLoopAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var line in _transportPair.ReadClientMessagesAsync(cancellationToken).ConfigureAwait(false)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + _manager.LogRawIn("[InProcess]", line); + + JsonRpcMessage? message; + try + { + message = await _manager.ReadMessageAsync(line).ConfigureAwait(false); + } + catch + { + message = null; + } + + switch (message) + { + case JsonRpcResponse response: + Log.Debug($"[McpServer][InProcess] Routing client response to session."); + _session.HandleResponseAsync(response); + continue; + + case JsonRpcNotification notification: + _ = HandleNotificationAsync(notification, cancellationToken); + continue; + + case JsonRpcRequest request: + _ = HandleRequestAsync(request, cancellationToken); + continue; + + default: + Log.Warn($"[McpServer][InProcess] Received unrecognizable message, responding with error."); + await _session.SendMessageAsync(new JsonRpcResponse + { + Error = new JsonRpcError + { + Code = (int)JsonRpcErrorCode.InvalidRequest, + Message = $"Invalid request message: {line}", + }, + }, cancellationToken).ConfigureAwait(false); + continue; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (ChannelClosedException) + { + } + catch (Exception ex) + { + Log.Error($"[McpServer][InProcess] Error in transport loop.", ex); + _transportPair.CompleteServer(ex); + return; + } + finally + { + _transportPair.CompleteServer(); + await _session.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task HandleNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken) + { + try + { + await _manager.HandleRequestAsync( + new JsonRpcRequest { Method = notification.Method, Params = notification.Params }, + services => services.AddTransportSession(_session, Log), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + Log.Error($"[McpServer][InProcess] Error handling client notification.", ex); + } + } + + private async Task HandleRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + try + { + var response = await _manager.HandleRequestAsync( + request, + services => services.AddTransportSession(_session, Log), + cancellationToken).ConfigureAwait(false); + if (response is not null) + { + await _session.SendMessageAsync(response, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + Log.Error($"[McpServer][InProcess] Error handling client request. Method={request.Method}, Id={request.Id}", ex); + try + { + await _session.SendMessageAsync(new JsonRpcResponse + { + Id = request.Id, + Error = new JsonRpcError + { + Code = (int)JsonRpcErrorCode.InternalError, + Message = ex.Message, + }, + }, cancellationToken).ConfigureAwait(false); + } + catch + { + // 连接可能已经关闭,无法再发送错误响应。 + } + } + } +} \ No newline at end of file diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransportSession.cs b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransportSession.cs new file mode 100644 index 0000000..de9f636 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessServerTransportSession.cs @@ -0,0 +1,63 @@ +using System.Text; +using DotNetCampus.ModelContextProtocol.Hosting.Logging; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; + +namespace DotNetCampus.ModelContextProtocol.Transports.InProcess; + +/// +/// In-Process 传输层的服务端会话。 +/// +public sealed class InProcessServerTransportSession : ServerTransportSession +{ + private readonly IServerTransportManager _manager; + private readonly InProcessTransportPair _transportPair; + private readonly IMcpLogger _logger; + + /// + /// 初始化 类的新实例。 + /// + /// 辅助管理 MCP 传输层的管理器。 + /// In-Process 传输层连接对。 + public InProcessServerTransportSession(IServerTransportManager manager, InProcessTransportPair transportPair) + { + _manager = manager; + _transportPair = transportPair; + _logger = manager.Context.Logger; + } + + /// + /// In-Process 传输层是专用的,不需要会话 ID。 + /// + public override string? SessionId => null; + + internal async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) + { + using var memoryStream = new MemoryStream(); + await _manager.WriteMessageAsync(memoryStream, message, cancellationToken).ConfigureAwait(false); + var json = Encoding.UTF8.GetString(memoryStream.GetBuffer(), 0, (int)memoryStream.Length); + _manager.LogRawOut("[InProcess]", json); + await _transportPair.SendToClientAsync(json, cancellationToken).ConfigureAwait(false); + } + + /// + protected override async Task SendRequestMessageAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + _logger.Debug($"[McpServer][InProcess] Sending server-initiated request. Method={request.Method}, Id={request.Id}, SessionId={SessionId}"); + await SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + protected override void OnResponseReceived(string id, JsonRpcResponse response) + => _logger.Debug($"[McpServer][InProcess] Received client response for pending request. Id={id}"); + + /// + protected override void OnUnmatchedResponse(string id, JsonRpcResponse response) + => _logger.Warn($"[McpServer][InProcess] Received unmatched client response. Id={id}"); + + /// + public override ValueTask DisposeAsync() + { + CancelAllPendingRequests(); + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportOptions.cs b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportOptions.cs new file mode 100644 index 0000000..b349cfd --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportOptions.cs @@ -0,0 +1,12 @@ +namespace DotNetCampus.ModelContextProtocol.Transports.InProcess; + +/// +/// In-Process 传输层选项。 +/// +public sealed record InProcessTransportOptions +{ + /// + /// 获取队列容量。为 时使用无界队列;为正整数时使用有界队列。 + /// + public int? Capacity { get; init; } +} \ No newline at end of file diff --git a/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportPair.cs b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportPair.cs new file mode 100644 index 0000000..ed68428 --- /dev/null +++ b/src/DotNetCampus.ModelContextProtocol/Transports/InProcess/InProcessTransportPair.cs @@ -0,0 +1,131 @@ +using System.Threading.Channels; + +namespace DotNetCampus.ModelContextProtocol.Transports.InProcess; + +/// +/// 一条 In-Process 传输层连接对。 +/// +public sealed class InProcessTransportPair : IAsyncDisposable +{ + private readonly Channel _clientToServer; + private readonly Channel _serverToClient; + private readonly TaskCompletionSource _serverStartedTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _clientAttached; + private int _serverAttached; + private int _completed; + + /// + /// 初始化 类的新实例。 + /// + public InProcessTransportPair() : this(new InProcessTransportOptions()) + { + } + + /// + /// 初始化 类的新实例。 + /// + /// 传输层选项。 + public InProcessTransportPair(InProcessTransportOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.Capacity is { } capacity && capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "In-Process 传输层队列容量必须为正整数。"); + } + + _clientToServer = CreateChannel(options); + _serverToClient = CreateChannel(options); + } + + internal void AttachClient() + { + if (Interlocked.CompareExchange(ref _clientAttached, 1, 0) != 0) + { + throw new InvalidOperationException("此 In-Process 传输层连接对已经绑定了客户端传输层。"); + } + } + + internal void AttachServer() + { + if (Interlocked.CompareExchange(ref _serverAttached, 1, 0) != 0) + { + throw new InvalidOperationException("此 In-Process 传输层连接对已经绑定了服务端传输层。"); + } + } + + internal void MarkServerStarted() + { + _serverStartedTaskCompletionSource.TrySetResult(); + } + + internal Task WaitForServerStartedAsync(CancellationToken cancellationToken) + { + return _serverStartedTaskCompletionSource.Task.WaitAsync(cancellationToken); + } + + internal IAsyncEnumerable ReadClientMessagesAsync(CancellationToken cancellationToken) + { + return _clientToServer.Reader.ReadAllAsync(cancellationToken); + } + + internal IAsyncEnumerable ReadServerMessagesAsync(CancellationToken cancellationToken) + { + return _serverToClient.Reader.ReadAllAsync(cancellationToken); + } + + internal ValueTask SendToServerAsync(string message, CancellationToken cancellationToken) + { + return _clientToServer.Writer.WriteAsync(message, cancellationToken); + } + + internal ValueTask SendToClientAsync(string message, CancellationToken cancellationToken) + { + return _serverToClient.Writer.WriteAsync(message, cancellationToken); + } + + internal void CompleteClient(Exception? exception = null) + { + _clientToServer.Writer.TryComplete(exception); + } + + internal void CompleteServer(Exception? exception = null) + { + if (Interlocked.Exchange(ref _completed, 1) != 0) + { + return; + } + + _serverStartedTaskCompletionSource.TrySetException(exception ?? new ObjectDisposedException(nameof(InProcessTransportPair))); + _clientToServer.Writer.TryComplete(exception); + _serverToClient.Writer.TryComplete(exception); + } + + /// + public ValueTask DisposeAsync() + { + CompleteServer(); + return ValueTask.CompletedTask; + } + + private static Channel CreateChannel(InProcessTransportOptions options) + { + if (options.Capacity is { } capacity) + { + return Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + AllowSynchronousContinuations = false, + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + }); + } + + return Channel.CreateUnbounded(new UnboundedChannelOptions + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false, + }); + } +} \ No newline at end of file diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs index e2424fb..b1222dc 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/TestMcpFactory.cs @@ -47,6 +47,14 @@ public async ValueTask CreateSimpleHttpAsync(HttpTransportTyp return await CreateHttpAsync(httpTransportType, t => t.WithTool(() => new SimpleTool())); } + /// + /// 创建一个简单的 In-Process 传输 MCP 测试包(仅包含 SimpleTool)。 + /// + public async ValueTask CreateSimpleInProcessAsync() + { + return await CreateInProcessCoreAsync(builder => builder.WithTools(t => t.WithTool(() => new SimpleTool()))); + } + /// /// 创建一个启用 2024-11-05 HTTP+SSE 兼容模式的 HTTP 服务端测试包。 /// @@ -113,6 +121,27 @@ public async ValueTask CreateFullHttpAsync(HttpTransportType CreateDefaultServices()); } + /// + /// 创建一个完整的 In-Process 传输 MCP 测试包(包含所有测试工具)。 + /// + public async ValueTask CreateFullInProcessAsync() + { + return await CreateInProcessCoreAsync( + builder => builder + .WithServices(CreateDefaultServices()) + .WithJsonSerializer(TestToolJsonContext.Default) + .WithTools(t => + { + t.WithTool(() => new SimpleTool()); + t.WithTool(() => new CalculatorTool()); + t.WithTool(() => new EchoTool()); + t.WithTool(() => new ExceptionTool()); + t.WithTool(() => new LongTextTool()); + t.WithTool(() => new StatefulCounterTool()); + t.WithTool(); + })); + } + /// /// 创建一个包含工具和资源的完整 HTTP 传输 MCP 测试包。 /// @@ -220,6 +249,32 @@ public async ValueTask CreateHttpCoreAsync( return new McpTestingPackage(mcpServer, builtClient, endpoint); } + /// + /// 核心方法:创建一个完全自定义的 In-Process 传输 MCP 测试包,支持同时配置服务端和客户端。 + /// + public async ValueTask CreateInProcessCoreAsync( + Action configureBuilder, + Action? configureClient = null) + { + var mcpServerBuilder = new McpServerBuilder("TestMcpServer", "1.0.0") + .WithLogger(DefaultLogger); + + configureBuilder(mcpServerBuilder); + mcpServerBuilder.WithInProcess(out var transportPair); + + var mcpServer = mcpServerBuilder.Build(); + mcpServer.EnableDebugMode(); + await mcpServer.StartAsync(CancellationToken.None); + + var mcpClientBuilder = new McpClientBuilder() + .WithLogger(DefaultLogger) + .WithInProcess(transportPair); + configureClient?.Invoke(mcpClientBuilder); + var builtClient = mcpClientBuilder.Build(); + + return new McpTestingPackage(mcpServer, builtClient, new Uri("inprocess://localhost/mcp", UriKind.Absolute)); + } + private static IServiceProvider CreateDefaultServices() { return new TestServiceProvider() diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/InProcessTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/InProcessTransportTests.cs new file mode 100644 index 0000000..543b944 --- /dev/null +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/InProcessTransportTests.cs @@ -0,0 +1,185 @@ +using System.Text.Json; +using System.Threading.Channels; +using DotNetCampus.ModelContextProtocol.Clients; +using DotNetCampus.ModelContextProtocol.Protocol; +using DotNetCampus.ModelContextProtocol.Protocol.Messages; +using DotNetCampus.ModelContextProtocol.Protocol.Messages.JsonRpc; +using DotNetCampus.ModelContextProtocol.Servers; +using DotNetCampus.ModelContextProtocol.Tests.McpTools; +using DotNetCampus.ModelContextProtocol.Transports.InProcess; + +namespace DotNetCampus.ModelContextProtocol.Tests.Transports; + +/// +/// In-Process 传输层测试。 +/// +[TestClass] +public class InProcessTransportTests +{ + [TestMethod("InProcess Connect: 连接成功并能调用工具")] + public async Task Connect() + { + await using var package = await TestMcpFactory.Shared.CreateSimpleInProcessAsync(); + + var result = await package.Client.ListToolsAsync(); + + Assert.IsTrue(package.Client.IsConnected); + Assert.AreEqual(1, result.Tools.Count); + Assert.AreEqual("add_number", result.Tools[0].Name); + } + + [TestMethod("InProcess Disconnect: 断开连接后资源正确释放")] + public async Task Disconnect() + { + var package = await TestMcpFactory.Shared.CreateSimpleInProcessAsync(); + + await package.Client.ListToolsAsync(); + Assert.IsTrue(package.Client.IsConnected); + + await package.DisposeAsync(); + } + + [TestMethod("InProcess CallTool: 正常调用 add 工具")] + public async Task CallTool() + { + await using var package = await TestMcpFactory.Shared.CreateFullInProcessAsync(); + var arguments = JsonSerializer.SerializeToElement(new { a = 10, b = 20 }); + + var result = await package.Client.CallToolAsync("add", arguments); + + Assert.AreNotEqual(true, result.IsError); + Assert.IsTrue(result.Content.Count > 0); + Assert.IsInstanceOfType(result.Content[0]); + var textContent = (TextContentBlock)result.Content[0]; + Assert.AreEqual("30", textContent.Text); + } + + [TestMethod("InProcess Initialized: initialized 通知能到达服务端")] + public async Task NotificationInitialized() + { + var initializedNotificationReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var package = await TestMcpFactory.Shared.CreateInProcessCoreAsync( + builder => builder + .WithTools(t => t.WithTool(() => new SimpleTool())) + .WithRequestHandlers(server => new InitializedTrackingRequestHandlers(server, initializedNotificationReceived))); + + await package.Client.ListToolsAsync(); + + await initializedNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsTrue(initializedNotificationReceived.Task.IsCompletedSuccessfully); + } + + [TestMethod("InProcess Sampling: 服务端工具可向客户端发起采样请求")] + public async Task ServerToolCanRequestSampling() + { + const string expectedResponseText = "Hello from InProcess sampling!"; + var samplingHandlerInvoked = false; + + await using var package = await TestMcpFactory.Shared.CreateInProcessCoreAsync( + configureBuilder: builder => builder.WithTools(t => t.WithTool(() => new SamplingTool())), + configureClient: clientBuilder => clientBuilder.WithSamplingHandler( + (parms, ct) => + { + samplingHandlerInvoked = true; + var result = new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = expectedResponseText }, + Model = "test-model", + StopReason = "endTurn", + }; + return Task.FromResult(result); + })); + + var toolArguments = JsonSerializer.SerializeToElement(new { message = "What's 2+2?" }); + var callResult = await package.Client.CallToolAsync("ask_llm", toolArguments); + + Assert.IsNotNull(callResult); + Assert.IsFalse(callResult.IsError); + Assert.IsTrue(samplingHandlerInvoked); + + var textContent = callResult.Content.OfType().FirstOrDefault(); + Assert.IsNotNull(textContent); + Assert.AreEqual(expectedResponseText, textContent.Text); + } + + [TestMethod("InProcess ConcurrentRequests: 并发请求能按 id 正确匹配响应")] + public async Task ConcurrentRequests() + { + await using var package = await TestMcpFactory.Shared.CreateFullInProcessAsync(); + + var tasks = Enumerable.Range(0, 20) + .Select(async index => + { + var arguments = JsonSerializer.SerializeToElement(new { a = index, b = index + 1 }); + var result = await package.Client.CallToolAsync("add", arguments); + var textContent = (TextContentBlock)result.Content[0]; + return int.Parse(textContent.Text); + }) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + CollectionAssert.AreEqual(Enumerable.Range(0, 20).Select(index => index + index + 1).ToArray(), results); + } + + [TestMethod("InProcess Connect: 服务端未启动时可取消等待")] + public async Task Connect_CancelWhenServerNotStarted() + { + await using var transportPair = new InProcessTransportPair(); + await using var client = new McpClientBuilder() + .WithInProcess(transportPair) + .Build(); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + var canceled = false; + try + { + await client.ListToolsAsync(cancellationToken: cancellationTokenSource.Token); + } + catch (OperationCanceledException) + { + canceled = true; + } + + Assert.IsTrue(canceled, "服务端未启动时,客户端连接等待应能被取消。"); + } + + [TestMethod("InProcess ServerStops: 服务端停止后客户端请求不会永久挂起")] + public async Task ServerStops_ClientRequestFailsFast() + { + await using var package = await TestMcpFactory.Shared.CreateSimpleInProcessAsync(); + await package.Client.ListToolsAsync(); + + await package.Server.StopAsync(); + + var failed = false; + try + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await package.Client.ListToolsAsync(cancellationToken: cancellationTokenSource.Token); + } + catch (Exception ex) when (ex is InvalidOperationException or OperationCanceledException or ChannelClosedException) + { + failed = true; + } + + Assert.IsTrue(failed, "服务端停止后,客户端请求应快速失败或被取消,而不是永久挂起。"); + } + + private sealed class InitializedTrackingRequestHandlers( + McpServer server, + TaskCompletionSource initializedNotificationReceived) : McpServerRequestHandlers(server) + { + protected override ValueTask OnNotificationReceivedAsync(JsonRpcRequest notification) + { + if (notification.Method == RequestMethods.NotificationsInitialized) + { + initializedNotificationReceived.TrySetResult(); + } + + return default; + } + } +} \ No newline at end of file diff --git a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/StdioTransportTests.cs b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/StdioTransportTests.cs index 9c3dbf0..b838971 100644 --- a/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/StdioTransportTests.cs +++ b/tests/DotNetCampus.ModelContextProtocol.Tests/Transports/StdioTransportTests.cs @@ -1,42 +1,41 @@ namespace DotNetCampus.ModelContextProtocol.Tests.Transports; /// -/// Stdio 传输层特性测试:分包、粘包、Header 解析。 +/// Stdio 传输层特性测试:换行分隔 JSON-RPC 消息读取。 /// /// -/// 目前 Stdio 传输在本库中尚未完全实现。 -/// 这些测试用例作为规划,待 Stdio 传输层完成后启用。 +/// 当前 Stdio 传输层直接绑定 Console 标准输入输出;后续若支持 Stream 注入,可在此补充无需外部进程的换行分隔消息测试。 /// [TestClass] public class StdioTransportTests { - // TODO: 待 Stdio 传输层实现后添加以下测试: + // TODO: 待 Stdio 传输层支持 Stream 注入后添加以下测试: // - // [TestMethod("Receive_ChunkedJson: 分包 JSON 能正确拼合")] - // public async Task Receive_ChunkedJson() + // [TestMethod("Receive_LineDelimitedJson: 单行 JSON 能正确解析")] + // public async Task Receive_LineDelimitedJson() // { - // // 将一个 JSON 报文拆成多个 byte 数组分次写入 Stream - // // 验证能够完整拼合并解析出消息 + // // 写入一行完整 JSON-RPC 消息,以 \n 结束。 + // // 验证能够解析出消息。 // } // - // [TestMethod("Receive_StickyPacket: 粘包 JSON 能正确分离")] - // public async Task Receive_StickyPacket() + // [TestMethod("Receive_MultipleLines: 连续多行 JSON 能逐条解析")] + // public async Task Receive_MultipleLines() // { - // // 将多个 JSON 报文一次性写入 Stream(粘包) - // // 验证能够依次触发多次消息接收 + // // 连续写入多条以 \n 分隔的 JSON-RPC 消息。 + // // 验证能够依次触发多次消息处理。 // } // - // [TestMethod("Receive_InvalidHeader: 错误的 Content-Length 应抛异常")] - // public async Task Receive_InvalidHeader() + // [TestMethod("Receive_InvalidMessage: 非 JSON-RPC 消息会返回 InvalidRequest")] + // public async Task Receive_InvalidMessage() // { - // // 写入错误的 Content-Length - // // 验证抛出协议异常或断开连接 + // // 写入无法解析为 JSON-RPC 的消息。 + // // 验证服务端返回 InvalidRequest 错误响应。 // } [TestMethod("Placeholder: Stdio 测试占位符")] public void Placeholder() { // 占位测试,确保测试类能够运行 - Assert.Inconclusive("Stdio 传输层测试尚未实现,待传输层完成后启用。"); + Assert.Inconclusive("Stdio 传输层 Stream 注入测试尚未实现,待支持后启用。"); } } From 11dd8a69d03421dafd08ce114d6081f3c5d1a70e Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 30 Apr 2026 11:11:01 +0800 Subject: [PATCH 52/77] =?UTF-8?q?=E8=A1=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/en/InProcessTransport.md | 48 ++++++++++++++++++++++++ docs/en/QuickStart.md | 8 ++++ docs/en/Sampling.md | 60 ++++++++++++++++++++++++++++++ docs/zh-hans/InProcessTransport.md | 48 ++++++++++++++++++++++++ docs/zh-hans/QuickStart.md | 8 ++++ docs/zh-hans/Sampling.md | 60 ++++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+) create mode 100644 docs/en/InProcessTransport.md create mode 100644 docs/en/Sampling.md create mode 100644 docs/zh-hans/InProcessTransport.md create mode 100644 docs/zh-hans/Sampling.md diff --git a/README.md b/README.md index 88caa37..29ecfa1 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ internal class Program internal partial class McpToolJsonContext : JsonSerializerContext; ``` +For embedding an MCP server in the same process or writing integration tests without a network server, see the [In-Process Transport Guide](/docs/en/InProcessTransport.md). + ### Declaring MCP Tool Methods ```csharp diff --git a/docs/en/InProcessTransport.md b/docs/en/InProcessTransport.md new file mode 100644 index 0000000..d506da3 --- /dev/null +++ b/docs/en/InProcessTransport.md @@ -0,0 +1,48 @@ +# In-Process Transport Guide + +## Usage + +```csharp +var mcpServer = new McpServerBuilder("Embedded Server", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + // WithInProcess creates the channel pair and returns it via the out parameter + .WithInProcess(out var transportPair) + .Build(); + +await mcpServer.StartAsync(); + +var mcpClient = new McpClientBuilder() + .WithInProcess(transportPair) + .Build(); + +var tools = await mcpClient.ListToolsAsync(); +``` + +Each `InProcessTransportPair` is a one-to-one connection — one pair can only be bound to one server and one client. For multiple concurrent clients, call `WithInProcess` multiple times on the server: + +```csharp +var mcpServer = new McpServerBuilder("Embedded Server", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithInProcess(out var pair1) + .WithInProcess(out var pair2) + .Build(); + +await mcpServer.StartAsync(); + +var client1 = new McpClientBuilder().WithInProcess(pair1).Build(); +var client2 = new McpClientBuilder().WithInProcess(pair2).Build(); +``` + +The In-Process transport fully supports Sampling. See the [Sampling Guide](Sampling.md) for details. + +Custom types used as tool parameters or return values must still be registered with `WithJsonSerializer` (the In-Process transport still uses JSON-RPC text format and does not bypass serialization): + +```csharp +var mcpServer = new McpServerBuilder("Embedded Server", "1.0.0") + .WithJsonSerializer(MyToolJsonContext.Default) // required for custom types + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithInProcess(out var transportPair) + .Build(); +``` + +The In-Process transport provides no process isolation. The server and client run in the same process under the same privilege level, so it is only suitable for trusted embedded or testing scenarios. diff --git a/docs/en/QuickStart.md b/docs/en/QuickStart.md index 66c486b..07db981 100644 --- a/docs/en/QuickStart.md +++ b/docs/en/QuickStart.md @@ -51,6 +51,14 @@ internal class Program internal partial class McpToolJsonContext : JsonSerializerContext; ``` +## In-Process Transport (Embedded Use) + +The In-Process transport is designed for embedding an MCP server in the same process or writing integration tests without a network server. See the [In-Process Transport Guide](InProcessTransport.md) for the full documentation. + +## Sampling (Server-Initiated Requests) + +MCP server tools can send a `sampling/createMessage` request to the client during execution, asking the client to call a language model and return the result. See the [Sampling Guide](Sampling.md) for details. + ## Declaring MCP Tool Methods ```csharp diff --git a/docs/en/Sampling.md b/docs/en/Sampling.md new file mode 100644 index 0000000..2adb3ad --- /dev/null +++ b/docs/en/Sampling.md @@ -0,0 +1,60 @@ +# Sampling (Server-Initiated Requests) + +Sampling is an MCP protocol feature that lets a server tool send a `sampling/createMessage` request to the client while executing, asking the client (the AI host) to call a language model and return the result. All transports (HTTP, stdio, In-Process, etc.) support this feature. + +## Client: register a handler + +Register a handler via `WithSamplingHandler` when building the client: + +```csharp +var mcpClient = new McpClientBuilder() + .WithLocalHostHttp(5943, "mcp") // any transport works the same way + .WithSamplingHandler(async (parameters, cancellationToken) => + { + // Call your actual AI model here + var response = await myAiModel.CompleteAsync(parameters.Messages); + return new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = response }, + Model = "my-model", + StopReason = "endTurn", + }; + }) + .Build(); +``` + +## Server: initiate a request from a tool + +Tool methods send the request via `IMcpServerCallToolContext.Sampling`: + +```csharp +public class MyTool +{ + [McpServerTool] + public async Task AskAI( + string question, + IMcpServerCallToolContext context, + CancellationToken cancellationToken) + { + var result = await context.Sampling.CreateMessageAsync( + new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = question }, + }, + ], + MaxTokens = 1024, + }, + cancellationToken); + + return result.Content is TextContentBlock text ? text.Text : string.Empty; + } +} +``` + +`IMcpServerCallToolContext` is an implicit parameter type — just declare it in the tool method's parameter list and the framework injects it automatically (see [Supported Types](QuickStart.md#supported-types)). diff --git a/docs/zh-hans/InProcessTransport.md b/docs/zh-hans/InProcessTransport.md new file mode 100644 index 0000000..11680cd --- /dev/null +++ b/docs/zh-hans/InProcessTransport.md @@ -0,0 +1,48 @@ +# In-Process 传输层使用指南 + +## 使用方式 + +```csharp +var mcpServer = new McpServerBuilder("内嵌服务器", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + // WithInProcess 同时创建连接对,后续传给客户端 + .WithInProcess(out var transportPair) + .Build(); + +await mcpServer.StartAsync(); + +var mcpClient = new McpClientBuilder() + .WithInProcess(transportPair) + .Build(); + +var tools = await mcpClient.ListToolsAsync(); +``` + +每个 `InProcessTransportPair` 是一对一连接,一个连接对只能绑定一个服务端和一个客户端。如果需要多个并发客户端,在服务器上多次调用 `WithInProcess` 即可: + +```csharp +var mcpServer = new McpServerBuilder("内嵌服务器", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithInProcess(out var pair1) + .WithInProcess(out var pair2) + .Build(); + +await mcpServer.StartAsync(); + +var client1 = new McpClientBuilder().WithInProcess(pair1).Build(); +var client2 = new McpClientBuilder().WithInProcess(pair2).Build(); +``` + +In-Process 传输层同样支持 Sampling,详见 [Sampling 使用指南](Sampling.md)。 + +自定义类型的参数和返回值同样需要通过 `WithJsonSerializer` 注册序列化上下文(In-Process 传输层仍然使用 JSON-RPC 文本格式,不绕过序列化): + +```csharp +var mcpServer = new McpServerBuilder("内嵌服务器", "1.0.0") + .WithJsonSerializer(MyToolJsonContext.Default) // 自定义类型必须注册 + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithInProcess(out var transportPair) + .Build(); +``` + +In-Process 传输层不提供进程隔离,服务端与客户端运行在同一进程和权限下,仅适用于同一信任边界内的嵌入式或测试场景。 diff --git a/docs/zh-hans/QuickStart.md b/docs/zh-hans/QuickStart.md index 9d00ba8..c8c5938 100644 --- a/docs/zh-hans/QuickStart.md +++ b/docs/zh-hans/QuickStart.md @@ -50,6 +50,14 @@ internal class Program internal partial class McpToolJsonContext : JsonSerializerContext; ``` +## In-Process 传输层(同进程嵌入) + +In-Process 传输层适用于同进程嵌入 MCP 服务或集成测试场景,详见 [In-Process 传输层使用指南](InProcessTransport.md)。 + +## Sampling(服务端主动请求) + +MCP 服务端工具可在执行期间向客户端发起 `sampling/createMessage` 请求,由客户端调用大语言模型后返回结果,详见 [Sampling 使用指南](Sampling.md)。 + ## MCP 工具方法声明 ```csharp diff --git a/docs/zh-hans/Sampling.md b/docs/zh-hans/Sampling.md new file mode 100644 index 0000000..f7742e7 --- /dev/null +++ b/docs/zh-hans/Sampling.md @@ -0,0 +1,60 @@ +# Sampling(服务端主动请求) + +Sampling 是 MCP 协议的一项功能,允许服务端工具在执行期间向客户端发起 `sampling/createMessage` 请求,由客户端(AI 宿主)调用大语言模型并返回结果。所有传输层(HTTP、stdio、In-Process 等)均支持此功能。 + +## 客户端:注册处理器 + +在构建客户端时,通过 `WithSamplingHandler` 注册处理器: + +```csharp +var mcpClient = new McpClientBuilder() + .WithLocalHostHttp(5943, "mcp") // 任意传输层均可 + .WithSamplingHandler(async (parameters, cancellationToken) => + { + // 在这里调用实际的 AI 模型 + var response = await myAiModel.CompleteAsync(parameters.Messages); + return new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = response }, + Model = "my-model", + StopReason = "endTurn", + }; + }) + .Build(); +``` + +## 服务端:在工具中发起请求 + +工具方法通过 `IMcpServerCallToolContext.Sampling` 发起请求: + +```csharp +public class MyTool +{ + [McpServerTool] + public async Task AskAI( + string question, + IMcpServerCallToolContext context, + CancellationToken cancellationToken) + { + var result = await context.Sampling.CreateMessageAsync( + new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = question }, + }, + ], + MaxTokens = 1024, + }, + cancellationToken); + + return result.Content is TextContentBlock text ? text.Text : string.Empty; + } +} +``` + +`IMcpServerCallToolContext` 是隐式参数类型,直接声明在工具方法参数列表中即可,无需额外配置(参见[支持的类型](QuickStart.md#支持的类型))。 From bb03919c8cfdf086ddf6f7fab52e51740a82a4ca Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 30 Apr 2026 15:15:21 +0800 Subject: [PATCH 53/77] =?UTF-8?q?=E4=BC=98=E5=8C=96=20CallToolResult.ToStr?= =?UTF-8?q?ing=20=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Protocol/Messages/CallToolResult.cs | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/CallToolResult.cs b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/CallToolResult.cs index 7e9bbb1..69191ce 100644 --- a/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/CallToolResult.cs +++ b/src/DotNetCampus.ModelContextProtocol/Protocol/Messages/CallToolResult.cs @@ -1,3 +1,6 @@ +using System.Buffers; +using System.Text; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using DotNetCampus.ModelContextProtocol.CompilerServices; @@ -71,12 +74,29 @@ public record CallToolResult : Result /// 表示当前实例的字符串。 public override string ToString() { - return Content switch + if (StructuredContent is { } structuredContent) { - [] => "", - [TextContentBlock { Text: var text }] => text, - _ => $"CallToolResult with {Content.Count} content blocks.", - }; + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + structuredContent.WriteTo(writer); + writer.Flush(); + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + if (Content.Count == 0) + { + return IsError == true ? "{\"error\":\"Call tool failed.\"}" : string.Empty; + } + + if (Content is [TextContentBlock { Text: var text }]) + { + return text; + } + + return string.Join("\n", Content.Select(x => x.ToString())); } /// From e234f781d4ba90ac930136c10558a61ca163968d Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 30 Apr 2026 15:15:39 +0800 Subject: [PATCH 54/77] =?UTF-8?q?Json=20=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E9=81=BF=E5=85=8D=E8=BE=93=E5=87=BA=20`\uxxx?= =?UTF-8?q?x`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CompilerServices/McpJsonContext.cs | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs b/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs index e507447..1e6c37b 100644 --- a/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs +++ b/src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Text.Encodings.Web; +using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using DotNetCampus.ModelContextProtocol.Exceptions; @@ -66,7 +67,16 @@ internal sealed class McpServerToolCompositeJsonContext(JsonSerializerContext ex // 协议类型 [JsonSerializable(typeof(CompiledJsonSchema))] [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization)] -public partial class CompiledSchemaJsonContext : JsonSerializerContext; +public partial class CompiledSchemaJsonContext : JsonSerializerContext +{ + static CompiledSchemaJsonContext() + { + Default = new CompiledSchemaJsonContext(new JsonSerializerOptions(s_defaultOptions) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } +} /// /// 与业务自己定义的 MCP 工具一起合并成 以序列化和反序列化业务定义的 MCP 工具参数、返回值和相关类型。 @@ -109,7 +119,16 @@ public partial class CompiledSchemaJsonContext : JsonSerializerContext; NumberHandling = JsonNumberHandling.AllowReadingFromString, UseStringEnumConverter = true, WriteIndented = false)] -internal partial class McpServerToolJsonContext : JsonSerializerContext; +internal partial class McpServerToolJsonContext : JsonSerializerContext +{ + static McpServerToolJsonContext() + { + Default = new McpServerToolJsonContext(new JsonSerializerOptions(s_defaultOptions) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } +} /// /// MCP 协议内部使用的统一 JSON 序列化上下文,涵盖所有请求参数类型和响应结果类型。 @@ -161,4 +180,13 @@ internal partial class McpServerToolJsonContext : JsonSerializerContext; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true, WriteIndented = false)] -internal partial class McpInternalJsonContext : JsonSerializerContext; +internal partial class McpInternalJsonContext : JsonSerializerContext +{ + static McpInternalJsonContext() + { + Default = new McpInternalJsonContext(new JsonSerializerOptions(s_defaultOptions) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } +} From 9e58f92af6ec96c2ed1cff5f16a9be49b0dd9911 Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 8 May 2026 14:35:40 +0800 Subject: [PATCH 55/77] Add json serialize doc --- docs/knowledge/json-unicode-escape.md | 109 ++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/knowledge/json-unicode-escape.md diff --git a/docs/knowledge/json-unicode-escape.md b/docs/knowledge/json-unicode-escape.md new file mode 100644 index 0000000..96a6167 --- /dev/null +++ b/docs/knowledge/json-unicode-escape.md @@ -0,0 +1,109 @@ +# System.Text.Json 非 ASCII 字符被转义为 \uXXXX 的问题与解决方案 + +System.Text.Json 默认将所有非 ASCII 字符转义为 `\uXXXX` 形式: + +```json +{"name":"\u5434\u519c","title":"\u5468\u65E5"} +``` + +这是合法的 JSON,标准解析器能正确还原,但大语言模型(LLM)有时会把 `\uXXXX` 当作字面字符串而非 Unicode 码位处理,导致对其中人名等内容的理解出错。 + +**根本原因**:`JavaScriptEncoder.Default` 是 HTML 安全编码器,对所有非 ASCII 字符一律转义。改用 `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` 可让非 ASCII 字符原样输出。 + +> **注意**:`UnsafeRelaxedJsonEscaping` 不转义 HTML 敏感字符(`<` `>` `&` `'`),因此不能将其输出内嵌到 HTML 页面或 `