diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c18a55..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) @@ -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) diff --git a/README.md b/README.md index 3d0e753..1929fa9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # DotNetCampus.ModelContextProtocol -[![.NET Build and Test](https://github.com/dotnet-campus/DotNetCampus.ModelContextProtocol/actions/workflows/dotnet-build.yml/badge.svg)](https://github.com/dotnet-campus/DotNetCampus.ModelContextProtocol/actions/workflows/dotnet-build.yml) [![NuGet](https://img.shields.io/nuget/v/DotNetCampus.ModelContextProtocol.svg?label=DotNetCampus.ModelContextProtocol)](https://www.nuget.org/packages/DotNetCampus.ModelContextProtocol) +[![.NET Build and Test](https://github.com/dotnet-campus/DotNetCampus.ModelContextProtocol/actions/workflows/dotnet-build.yml/badge.svg)](https://github.com/dotnet-campus/DotNetCampus.ModelContextProtocol/actions/workflows/dotnet-build.yml) [![NuGet](https://img.shields.io/nuget/v/DotNetCampus.ModelContextProtocol.svg?label=DotNetCampus.ModelContextProtocol)](https://www.nuget.org/packages/DotNetCampus.ModelContextProtocol) [![NuGet](https://img.shields.io/nuget/v/DotNetCampus.ModelContextProtocol.Ipc.svg?label=DotNetCampus.ModelContextProtocol.Ipc)](https://www.nuget.org/packages/DotNetCampus.ModelContextProtocol.Ipc) [![NuGet](https://img.shields.io/nuget/v/DotNetCampus.ModelContextProtocol.TouchSocket.Http.svg?label=DotNetCampus.ModelContextProtocol.TouchSocket.Http)](https://www.nuget.org/packages/DotNetCampus.ModelContextProtocol.TouchSocket.Http) -| [English][en] | [简体中文][zh-hans] | -| ------------- | ------------------- | +| [English][en] | [简体中文][zh-hans] | [繁體中文][zh-hant] | +| ------------- | ------------------- | ------------------- | -[en]: /docs/en/QuickStart.md -[zh-hans]: /docs/zh-hans/QuickStart.md +[en]: /docs/en/README.md +[zh-hans]: /docs/zh-hans/README.md +[zh-hant]: /docs/zh-hant/README.md A lightweight, zero-dependency yet full-featured MCP protocol implementation built with .NET. It can be easily integrated into your application, regardless of its architecture. @@ -25,87 +26,56 @@ A lightweight, zero-dependency yet full-featured MCP protocol implementation bui dotnet add package DotNetCampus.ModelContextProtocol ``` -### Quick Start +## Quick Start -A typical MCP server program looks like this: +### Server ```csharp -internal class Program -{ - private static async Task Main(string[] args) - { - // The server name and version will be sent to clients via the MCP protocol - var mcpServer = new McpServerBuilder("Sample Server", "1.0.0") - // If your MCP tool parameters and return values use custom types, you need to provide a JSON serialization context - .WithJsonSerializer(McpToolJsonContext.Default) - .WithTools(t => t - // Register various MCP tools - .WithTool(() => new SampleTools()) - .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, - // as the former typically requires singleton execution while the latter must support multiple instances - // .WithStdio() - .Build(); -#if DEBUG - // Enable debug mode so that when the MCP server encounters exceptions, it returns exception information to clients for easier debugging - // It's generally not recommended to enable this mode in production, as it would expose internal implementation details of the server - mcpServer.EnableDebugMode(); -#endif - // Run the MCP server - await mcpServer.RunAsync(); - } -} +var mcpServer = new McpServerBuilder("Sample Mcp Server", "1.0.0") + .WithTools(tools => tools.WithTool(() => new SampleTools())) + .WithLocalHostHttp(5943, "mcp") + .Build(); -[JsonSerializable(typeof(Foo))] -[JsonSerializable(typeof(Bar))] -[JsonSourceGenerationOptions( - // Recommended: Most MCP protocol implementations use camelCase naming - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - // Recommended: Most MCP protocol implementations use string enums - UseStringEnumConverter = true, - // Recommended: Cannot guarantee AI will always put metadata properties first - AllowOutOfOrderMetadataProperties = true - // If you plan to use less capable models, you can also enable the following options - // PropertyNameCaseInsensitive = true, - // NumberHandling = JsonNumberHandling.AllowReadingFromString - )] -internal partial class McpToolJsonContext : JsonSerializerContext; -``` - -### Declaring MCP Tool Methods +await mcpServer.RunAsync(); -```csharp public class SampleTools { /// - /// A tool for AI debugging that echoes back information as-is + /// 原样返回输入文本。 /// - /// The string to echo back - /// The echoed string + /// 要原样返回的字符串 [McpServerTool(ReadOnly = true)] - public string Echo(string text) + public string EchoTool(string text) { return text; } } ``` -### Advanced Usage +### Client + +```csharp +var client = new McpClientBuilder("Sample Mcp Client", "1.0.0") + .WithHttp("http://localhost:5943/mcp") + .Build(); + +var arguments = JsonSerializer.SerializeToElement(new { text = "Hello, MCP!" }); +var result = await client.CallToolAsync("echo_tool", arguments); + +Console.WriteLine(result.Content); +``` + +## Documentation -For advanced usage including supported types for parameters and return values, type polymorphism, and more, please refer to the [Quick Start Guide](docs/quickstart/README.md) +See [docs/en/README.md](docs/en/README.md) for the full documentation index. ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome. Please feel free to submit a pull request. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. ## About dotnet-campus diff --git a/build/Version.props b/build/Version.props index 2fb575c..14a9f7c 100644 --- a/build/Version.props +++ b/build/Version.props @@ -1,5 +1,5 @@ - 0.1.0 + 0.0.0 diff --git "a/docs/McpClient\350\256\276\350\256\241\346\226\271\346\241\210.md" "b/docs/McpClient\350\256\276\350\256\241\346\226\271\346\241\210.md" deleted file mode 100644 index 880736e..0000000 --- "a/docs/McpClient\350\256\276\350\256\241\346\226\271\346\241\210.md" +++ /dev/null @@ -1,59 +0,0 @@ -# McpClient 设计方案 - -现有的 `McpServer` 的初始化代码如下: - -```csharp -var mcpServer = new McpServerBuilder("SampleMcpServer", "1.0.0") - .WithLogger(new McpLoggerBridge(Log.Current)) - .WithLocalHostHttp(new LocalHostHttpTransportOptions - { - Port = 5943, - EndPoint = "mcp", - IsCompatibleWithSse = true, - }) - // .WithStdio() - .WithJsonSerializer(McpToolJsonContext.Default) - .WithTools(t => t - .WithTool(() => new SampleTool()) - .WithTool(() => new InputTool()) - .WithTool(() => new OutputTool()) - .WithTool(() => new PolymorphicTool()) - .WithTool(() => new ResourceTool()) - ) - .WithResources(r => r - .WithResource(() => new SampleResource()) - ) - .Build(); -mcpServer.EnableDebugMode(); -await mcpServer.RunAsync(); -``` - -那么,预期的 `McpClient` 的初始化代码设计方案如下: - -```csharp -var mcpClient = new McpClientBuilder() - .WithLogger(new McpLoggerBridge(Log.Current)) - .WithHttp(new HttpClientTransportOptions - { - EndPoint = "http://localhost:5943/mcp", - }) - // .WithStdio(new StdioClientTransportOptions - // { - // Command = "dnx", - // Arguments = ["xxx"], - // }) - .Build(); -``` - -连接方案有三: - -1. `McpClient` 是已连接的对象,`Build` 方法改为 `BuildAsync`,负责连接。这也是微软官方采用的方案。 -1. `McpClient` 可以是未连接的对象,不会自动连接,但在每个与服务器通信的方法(如 `ListToolsAsync`)调用前都会确保连接或重连。 -1. `Build` 出来的是 `McpClientInfo`,不负责连接,也没有与服务器通信的方法,调用 `ConnectAsync` 方法后返回 `McpClient` 对象,负责与服务器通信。 - -我们选方案二。 - -注: - -- MCP 官方协议要求 `McpClient` 与 `McpServer` 是一对一对应关系 -- MCP 官方协议要求 MCP 主机(`McpHost`)负责管理多个 `McpClient` diff --git a/docs/en/Authorization.md b/docs/en/Authorization.md new file mode 100644 index 0000000..d3f1e62 --- /dev/null +++ b/docs/en/Authorization.md @@ -0,0 +1,5 @@ +# Authorization + +MCP Authorization enables the client and server to complete authentication and authorization before or during a connection. + +> The current version does not yet provide built-in Authorization support (planned). In the meantime, you can implement authentication logic yourself using custom HTTP headers (via `HttpClientTransportOptions`'s `HttpClient`) or `WithRequestHandlers` interceptors. diff --git a/docs/en/DependencyInjection.md b/docs/en/DependencyInjection.md new file mode 100644 index 0000000..a966b56 --- /dev/null +++ b/docs/en/DependencyInjection.md @@ -0,0 +1,128 @@ +# Dependency Injection + +The MCP library's dependency injection is entirely implemented by **compile-time source generators**, with **zero runtime reflection**. This document explains how it works, how to use it, and how it differs from conventional DI containers. + +## Core Principle + +When you write: + +```csharp +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithServices(appServiceProvider) + .WithTools(tools => tools + .WithTool() + .WithTool()) + .Build(); +``` + +What actually happens at compile time: + +1. **`WithServices(IServiceProvider)`** merely stores your `IServiceProvider` reference — **no service registration or container scanning** occurs. +2. **`WithTool()`** has a method body that is literally `throw new InvalidOperationException()` (never executed). The C# 12 **Interceptors** feature intercepts this call at compile time. +3. **The compile-time generated interceptor code** uses Roslyn to analyze `MyTool`'s constructor signature and generates explicit `serviceProvider.GetService(typeof(TParam))` calls for each constructor parameter. + +> **Key takeaway: Tool classes do NOT need to be registered in the DI container.** The `IServiceProvider` is only used to resolve the **parameter types** of the tool's constructor (such as `ILogger`, `HttpClient`, etc.). + +## Two Injection Approaches + +### Approach 1: Constructor Injection (`WithTool()`, Recommended) + +Best for tool classes with multiple shared dependencies. The source generator (`WithToolInterceptorGenerator`) finds the constructor at compile time and generates `GetService` calls for each parameter: + +```csharp +// User code +public class MyTool +{ + private readonly ILogger _logger; + private readonly IDataService _dataService; + + public MyTool(ILogger logger, IDataService dataService) + { + _logger = logger; + _dataService = dataService; + } + + /// + /// Process input and return result. + /// + [McpServerTool] + public string DoSomething(string input) + { + _logger.Info($"processing: {input}"); + return _dataService.Process(input); + } +} + +// Registration — no factory needed +builder.WithServices(appServiceProvider); +builder.WithTool(); // Interceptor auto-generates DI code +``` + +Equivalent code generated at compile time (simplified): + +```csharp +// Generated by interceptor at compile time, zero runtime reflection +var factory = () => new MyTool( + (ILogger?)serviceProvider.GetService(typeof(ILogger)) + ?? throw new InvalidOperationException("Unable to resolve ILogger."), + (IDataService?)serviceProvider.GetService(typeof(IDataService)) + ?? throw new InvalidOperationException("Unable to resolve IDataService.")); +``` + +### Approach 2: Parameter Injection (`[ToolParameter(Type = ToolParameterType.Injected)]`) + +Best when only a few parameters need DI. The source generator (`McpServerToolSourceBuilder`) generates independent `GetService` calls for each injected parameter: + +```csharp +public class SampleTools +{ + [McpServerTool] + public string FormatMessage( + string text, + [ToolParameter(Type = ToolParameterType.Injected)] ILogger logger) + { + logger.Info($"formatting: {text}"); + return text.ToUpper(); + } +} +``` + +Equivalent code generated at compile time: + +```csharp +// Nullable types → TryGetService, returns null on resolution failure +var logger = context.TryGetService(); + +// Non-nullable types → EnsureGetService, throws on resolution failure +var requiredService = context.EnsureGetService("IRequiredService"); +``` + +## What You Need to Configure + +| Action | Required? | Notes | +|--------|-----------|-------| +| Register tool types in DI container | **No** | Source generator has already analyzed constructors; `new` is used directly at runtime | +| Register constructor parameter types in DI container | **Yes** | Types like `ILogger`, `IDataService` must be resolvable from `IServiceProvider` | +| Call `WithServices()` | **Yes** | Passes your `IServiceProvider` to the MCP server | +| Call `WithTool()` (without factory) | **Yes** | Triggers the source generator to emit DI code for this type | +| Call `WithTool(() => new MyTool(dep1))` | Optional | Manual instantiation; no `IServiceProvider` needed | + +## Comparison with Conventional DI Containers + +| | Conventional DI (e.g. `Microsoft.Extensions.DI`) | MCP Library | +|---|---|---| +| Service discovery | Runtime assembly scanning | Compile-time Roslyn source analysis | +| Instance creation | Runtime `Activator.CreateInstance` | Compile-time `new T(...)` expressions | +| Tool registration | `services.AddTransient()` | **Not needed** | +| Parameter injection | Container recursive type-tree resolution | Compile-time generated `serviceProvider.GetService(typeof(T))` | +| Resolution failure | Runtime exception | Nullable params return `null`; non-nullable throw | + +## Safety + +If the `WithTool()` interceptor is missing (e.g., forgot to reference the Analyzer NuGet package), the actual method body is `throw new InvalidOperationException` — the application fails immediately at startup with a clear error, rather than silently using the wrong resolution mechanism. + +## Why Not Reflection + +1. **AOT-compatible**: No `Activator.CreateInstance` or assembly scanning; fully compatible with NativeAOT compilation. +2. **Compile-time error detection**: Unresolvable constructor parameters produce `#error` at compile time, no need to wait until runtime. +3. **Zero overhead**: Generated code performs identically to hand-written `new MyTool(dep1, dep2)`. diff --git a/docs/en/Elicitation.md b/docs/en/Elicitation.md new file mode 100644 index 0000000..f5a5888 --- /dev/null +++ b/docs/en/Elicitation.md @@ -0,0 +1,5 @@ +# Elicitation + +MCP Elicitation allows the server to request additional information from the client, which the client then collects from the user. + +> The current version has not yet implemented Elicitation (planned). diff --git a/docs/en/McpServerManager.md b/docs/en/McpServerManager.md new file mode 100644 index 0000000..30e5c2e --- /dev/null +++ b/docs/en/McpServerManager.md @@ -0,0 +1,312 @@ +# McpServerManager + +The MCP protocol specifies that a single client can only connect to one MCP server. However, in agent programs, you typically need to manage multiple MCP servers simultaneously (built-in tools, external services, position programs, etc.), and locate the target tool using the `{serverName}.{toolName}` format when making calls. + +Below is a manager example for managing multiple MCP servers simultaneously. + +> This example follows the [Client "Build Before Connect" Principle](../knowledge/client-build-before-connect.md): `McpClientBuilder.Build()` only creates client objects and does not trigger I/O. The registration phase completes synchronously, and the connection phase executes concurrently. + +## Complete Code + +```csharp +/// +/// MCP server manager that manages connections to multiple MCP servers and generates exposed names in {serverName}.{toolName} format. +/// +public sealed class McpServerManager : IAsyncDisposable +{ + private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(5); + private readonly TimeSpan _connectTimeout; + private readonly Dictionary _servers = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tools = new(StringComparer.OrdinalIgnoreCase); + + public McpServerManager(TimeSpan? connectTimeout = null) + { + _connectTimeout = connectTimeout ?? DefaultConnectTimeout; + } + + /// + /// Lists all registered servers (including unconnected ones), suitable for UI display. + /// + public IReadOnlyDictionary ListServers() + { + return _servers.ToDictionary(x => x.Key, x => x.Value); + } + + /// + /// Lists the exposed names of all connected tools. + /// + public IReadOnlyList ListToolNames() + { + return _tools.Keys + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + // ======== Phase 1: Registration (synchronous, no I/O, returns immediately) ======== + + /// + /// Registers an HTTP MCP server configuration (does not establish an actual connection). + /// The connection is lazily triggered in or on the first tool call. + /// + public void AddHttp( + string serverName, string serverUrl, + IReadOnlyDictionary? headers = null) + { + if (_servers.ContainsKey(serverName)) + { + throw new InvalidOperationException($"MCP server already exists: {serverName}"); + } + + var client = CreateHttpClient(serverName, serverUrl, headers); + _servers[serverName] = new McpServerRuntimeState(serverName, "http", client); + } + + /// + /// Registers a stdio MCP server configuration (does not establish an actual connection). + /// + public void AddStdio( + string serverName, + string command, + IReadOnlyList? arguments = null, + IReadOnlyDictionary? env = null) + { + if (_servers.ContainsKey(serverName)) + { + throw new InvalidOperationException($"MCP server already exists: {serverName}"); + } + + var client = new McpClientBuilder($"McpManager/{serverName}", "1.0.0") + .WithStdio(new StdioClientTransportOptions + { + Command = command, + Arguments = arguments ?? [], + EnvironmentVariables = env is null + ? new Dictionary() + : new Dictionary(env, StringComparer.OrdinalIgnoreCase), + }) + .Build(); + + _servers[serverName] = new McpServerRuntimeState(serverName, "stdio", client); + } + + // ======== Phase 2: Connection (concurrent) ======== + + /// + /// Connects all registered but not yet connected servers concurrently. + /// The connection timeout and errors for each server do not affect each other. + /// + public async Task ConnectAllAsync(CancellationToken cancellation = default) + { + var unconnected = _servers.Values.Where(s => !s.IsConnected).ToList(); + if (unconnected.Count == 0) + { + return; + } + + // Concurrent connection: one server timing out will not block others + var tasks = unconnected.Select(s => ConnectAndRegisterAsync(s, cancellation)); + await Task.WhenAll(tasks); + } + + // ======== Invocation and Querying ======== + + /// + /// Calls the specified tool. + /// + public async Task CallToolAsync( + string exposedName, + JsonElement? arguments = null, + CancellationToken cancellation = default) + { + if (!_tools.TryGetValue(exposedName, out var endpoint)) + { + return CallToolResult.FromError($"Tool not found: {exposedName}"); + } + + return await endpoint.Client.CallToolAsync(endpoint.ToolName, arguments, cancellation); + } + + // ======== Lifecycle ======== + + /// + /// Removes an MCP server and all its associated tools. + /// + public async Task RemoveAsync(string serverName) + { + if (!_servers.Remove(serverName, out var state)) + { + return false; + } + + // Remove all tools associated with this server + foreach (var key in _tools.Keys.ToList()) + { + if (ReferenceEquals(_tools[key].Client, state.Client)) + { + _tools.Remove(key); + } + } + + if (state.Client is not null) + { + await state.Client.DisposeAsync(); + } + + return true; + } + + /// + public async ValueTask DisposeAsync() + { + foreach (var state in _servers.Values) + { + if (state.Client is not null) + { + await state.Client.DisposeAsync(); + } + } + + _servers.Clear(); + _tools.Clear(); + } + + // ======== Internal Implementation ======== + + private static McpClient CreateHttpClient( + string serverName, string url, IReadOnlyDictionary? headers) + { + var httpClient = new HttpClient(); + if (headers is not null) + { + foreach (var (key, value) in headers) + { + httpClient.DefaultRequestHeaders.TryAddWithoutValidation(key, value); + } + } + + return new McpClientBuilder($"McpManager/{serverName}", "1.0.0") + .WithHttp(new HttpClientTransportOptions + { + ServerUrl = url, + HttpClient = httpClient, + }) + .Build(); + } + + private async Task ConnectAndRegisterAsync( + McpServerRuntimeState state, CancellationToken cancellation) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + cts.CancelAfter(_connectTimeout); + + // EnsureConnectedAsync is the lazy connection entry point; Build() does not establish a connection + await state.Client.EnsureConnectedAsync(cancellationToken: cts.Token); + var toolsResult = await state.Client.ListToolsAsync(cancellationToken: cts.Token); + var tools = toolsResult.Tools; + + foreach (var tool in tools) + { + var exposedName = $"{state.Name}.{tool.Name}"; + if (_tools.ContainsKey(exposedName)) + { + throw new InvalidOperationException($"Tool name conflict: {exposedName}"); + } + + _tools[exposedName] = new McpToolEndpoint(state.Client, tool.Name, exposedName); + } + + state.IsConnected = true; + state.ToolCount = tools.Count; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Record state even on connection failure, so upstream can query the failure reason + state.IsConnected = false; + state.Error = ex.Message; + } + } +} + +/// +/// Tool endpoint that records a tool's client, original name, and exposed name. +/// +/// The MCP client the tool belongs to. +/// The tool's original name on the MCP server. +/// The externally exposed tool name (e.g. {serverName}.{toolName}). +public sealed record McpToolEndpoint(McpClient Client, string ToolName, string ExposedName); + +/// +/// MCP server runtime state (connection state and tool list). +/// +public sealed class McpServerRuntimeState +{ + public McpServerRuntimeState(string name, string transportType, McpClient client) + { + Name = name; + TransportType = transportType; + Client = client; + } + + public string Name { get; } + public string TransportType { get; } + public McpClient Client { get; } + public bool IsConnected { get; set; } + public string? Error { get; set; } + public int ToolCount { get; set; } +} +``` + +## Usage Example + +```csharp +await using var manager = new McpServerManager(); + +// ====== Phase 1: Registration (synchronous, returns immediately, no connection) ====== +manager.AddHttp( + "everything", + "http://localhost:3001/mcp", + headers: new Dictionary { ["Authorization"] = "Bearer xxx" }); + +manager.AddStdio( + "python-tools", + "python", + arguments: ["-m", "my_mcp_server"], + env: new Dictionary { ["PYTHONPATH"] = "/modules" }); + +// At this point, you can display registered servers (including unconnected ones) for UI purposes +foreach (var (name, state) in manager.ListServers()) +{ + Console.WriteLine($"{name}: {(state.IsConnected ? "Connected" : "Not Connected")}"); +} + +// ====== Phase 2: Concurrent Connection ====== +// Multiple servers connect simultaneously without blocking each other +await manager.ConnectAllAsync(); + +// Now you can list tools and make calls +foreach (var toolName in manager.ListToolNames()) +{ + Console.WriteLine(toolName); +} + +var result = await manager.CallToolAsync( + "everything.echo", + JsonSerializer.SerializeToElement(new { message = "Hello, MCP!" })); + +Console.WriteLine(result.Content); +``` + +## Design Highlights + +- **Separation of Registration and Connection**: `AddHttp`/`AddStdio` are synchronous methods that only save configuration (no I/O); connections are handled uniformly by `ConnectAllAsync`. This is made possible by `McpClientBuilder.Build()` not triggering connections. +- **Concurrent Connection**: `ConnectAllAsync` uses `Task.WhenAll` to connect all servers concurrently. Each server's timeout is independent — one broken server will not block connections to others. +- **UI-Friendly**: After registration, the server list can be displayed immediately (`ListServers()` includes unconnected states) without waiting for connections to complete. +- **Error Fault Tolerance**: A single server connection failure does not throw an exception; instead, it records `state.IsConnected = false` and `state.Error`, without affecting other servers. +- **O(1) Tool Lookup**: The `_tools` dictionary is indexed by exposed name; `CallToolAsync` does not require iteration. +- **Tool Name Conflict Detection**: When adding tools, the exposed name is checked for conflicts with names already claimed by other servers. diff --git a/docs/en/Meta.md b/docs/en/Meta.md new file mode 100644 index 0000000..a9db0c1 --- /dev/null +++ b/docs/en/Meta.md @@ -0,0 +1,123 @@ +# _meta + +The [`_meta`](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta) field in the MCP protocol allows carrying additional metadata in MCP requests. A typical use case is **distributed tracing**: the client injects a TraceId into `_meta`, and the server extracts it for instrumentation. + +## Client: Inject TraceId + +The client inherits `McpClientRequestHandlers` and overrides the `OnRequestSending` global hook to inject the TraceId of the current `Activity.Current` into `_meta`: + +```csharp +public sealed class TracingClientRequestHandlers(McpClient client) : McpClientRequestHandlers(client) +{ + protected internal override void OnRequestSending(RequestParams requestParams, string method) + { + var activity = Activity.Current; + if (activity is null) + { + return; + } + + var meta = new Dictionary + { + ["traceparent"] = activity.Id, + }; + if (!string.IsNullOrEmpty(activity.TraceStateString)) + { + meta["tracestate"] = activity.TraceStateString; + } + + requestParams.Meta = JsonSerializer.SerializeToElement(meta); + } +} +``` + +> **Tip**: You can also override individual methods like `CallToolAsync` or `ListToolsAsync` to inject `_meta` per-request. +> In contrast, overriding `OnRequestSending` once covers all request types and is the recommended approach. + +Register with the client: + +```csharp +var mcpClient = new McpClientBuilder("Sample Client", "1.0.0") + .WithHttp("http://localhost:5943/mcp") + .WithRequestHandlers(client => new TracingClientRequestHandlers(client)) + .Build(); +``` + +## Server: Extract TraceId and Create Activity + +The server intercepts requests via `McpServerRequestHandlers`, extracts the TraceId from `_meta`, and creates a child Activity for instrumentation: + +```csharp +public sealed class TracingServerRequestHandlers(McpServer server) : McpServerRequestHandlers(server) +{ + public override async ValueTask CallToolAsync( + RequestContext rawRequest, + string? toolName, IMcpServerTool? tool, IMcpServerCallToolContext? context) + { + var meta = rawRequest.Params?.Meta; + var parentId = meta?.TryGetProperty("traceparent", out var tpElement) == true + ? tpElement.GetString() + : null; + + using var activity = parentId is not null + ? ActivitySource.StartActivity("tools/call", ActivityKind.Server, parentId) + : null; + + activity?.SetTag("mcp.method.name", "tools/call"); + activity?.SetTag("gen_ai.tool.name", toolName); + + var result = await base.CallToolAsync(rawRequest, toolName, tool, context); + + if (result.RawException is { } ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + } + + return result; + } +} +``` + +Register with the server: + +```csharp +var mcpServer = new McpServerBuilder("Sample Server", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithLocalHostHttp(5943, "mcp") + .WithRequestHandlers(s => new TracingServerRequestHandlers(s)) + .Build(); +``` + +In addition to global interception via `McpServerRequestHandlers`, you can also read `Context.Meta` directly in individual tool or resource methods: + +```csharp +// In a tool method +[McpServerTool] +public string EchoWithTrace(IMcpServerCallToolContext context, string text) +{ + if (context.Meta.TryGetProperty("traceparent", out var tp)) + { + Console.WriteLine($"TraceId: {tp.GetString()}"); + } + return text; +} + +// In a resource method +[McpServerResource(UriTemplate = "sample://status", Name = "Server Status")] +public string GetStatus(IMcpServerReadResourceContext context) +{ + if (context.Meta.TryGetProperty("traceparent", out var tp)) + { + Console.WriteLine($"TraceId: {tp.GetString()}"); + } + return "OK"; +} +``` + +This approach is suitable for scenarios where you only need to read `_meta` in a few individual methods. + +## _meta and Distributed Tracing + +- The MCP protocol does not define a standard Trace Context propagation mechanism, but the OpenTelemetry community recommends passing `traceparent` and `tracestate` through `params._meta` +- This convention is under discussion in the MCP community and may become an official specification in the future ([modelcontextprotocol#246](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/246), [modelcontextprotocol#414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) +- Until an MCP standard is established, you can implement TraceContext injection and extraction yourself using this library's `WithRequestHandlers` diff --git a/docs/en/Prompts.md b/docs/en/Prompts.md new file mode 100644 index 0000000..06c48d4 --- /dev/null +++ b/docs/en/Prompts.md @@ -0,0 +1,5 @@ +# Prompts + +MCP Prompts allows the server to provide reusable prompt templates. The client can list templates and fetch prompt content by name. + +> The current version has not yet implemented Prompts (planned). diff --git a/docs/en/QuickStart.md b/docs/en/QuickStart.md index cdc77fd..ee39be4 100644 --- a/docs/en/QuickStart.md +++ b/docs/en/QuickStart.md @@ -1,6 +1,10 @@ # Quick Start -## Initialization +For detailed explanation of the MCP protocol, see the [official MCP documentation](https://modelcontextprotocol.io/docs/getting-started/intro). + +## Server + +### Initialization A typical MCP server program looks like this: @@ -9,28 +13,22 @@ internal class Program { private static async Task Main(string[] args) { - // The server name and version will be sent to clients via the MCP protocol - var mcpServer = new McpServerBuilder("Sample Server", "1.0.0") + // The server name and version are sent to the client via the MCP protocol + var mcpServer = new McpServerBuilder("Example Server", "1.0.0") // If your MCP tool parameters and return values use custom types, you need to provide a JSON serialization context .WithJsonSerializer(McpToolJsonContext.Default) .WithTools(t => t // Register various MCP tools .WithTool(() => new SampleTools()) - .WithTool(() => new SampleTools2()) + // .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, - // as the former typically requires singleton execution while the latter must support multiple instances + // You can also use stdio (standard input/output), which is the transport layer that MCP recommends all servers support + // However, it is generally not recommended to enable both http and stdio simultaneously, because the former typically requires singleton operation, while the latter must support multi-instance operation // .WithStdio() .Build(); -#if DEBUG - // Enable debug mode so that when the MCP server encounters exceptions, it returns exception information to clients for easier debugging - // It's generally not recommended to enable this mode in production, as it would expose internal implementation details of the server - mcpServer.EnableDebugMode(); -#endif + // Run the MCP server await mcpServer.RunAsync(); } @@ -39,29 +37,29 @@ internal class Program [JsonSerializable(typeof(Foo))] [JsonSerializable(typeof(Bar))] [JsonSourceGenerationOptions( - // Recommended: Most MCP protocol implementations use camelCase naming + // Recommended: mainstream MCP protocol implementations use camelCase PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - // Recommended: Most MCP protocol implementations use string enums + // Recommended: mainstream MCP protocol implementations use string enums UseStringEnumConverter = true, - // Recommended: Cannot guarantee AI will always put metadata properties first + // Recommended: there is no guarantee that AI will always place metadata properties first AllowOutOfOrderMetadataProperties = true - // If you plan to use less capable models, you can also enable the following options + // If you plan to use a less capable model, you can also enable the following options // PropertyNameCaseInsensitive = true, // NumberHandling = JsonNumberHandling.AllowReadingFromString )] internal partial class McpToolJsonContext : JsonSerializerContext; ``` -## Declaring MCP Tool Methods +### MCP Tool Method Declaration ```csharp public class SampleTools { /// - /// A tool for AI debugging that echoes back information as-is + /// A debugging tool for AI that echoes back some information as-is. /// - /// The string to echo back - /// The echoed string + /// The string to echo back. + /// The echoed string. [McpServerTool(ReadOnly = true)] public string Echo(string text) { @@ -70,70 +68,64 @@ public class SampleTools } ``` -### Supported Types +For a complete explanation of method parameters, return value types, and sync/async behavior, see [Tools - Parameters and Return Values](Tools.md#parameters-and-context). -Method parameters can be of any number and support the following types: +## Client -- Implicit types: - - Any type that can be JSON deserialized (including primitive types, arrays, objects, etc.) - - `CancellationToken`: Represents a cancellation token - - `IMcpServerCallToolContext`: Represents the context information of the current tool method - - `JsonElement`: Represents arbitrary JSON data -- Explicit types: - - `[ToolParameter(Type = ToolParameterType.InputObject)]`: Indicates this parameter receives the entire input object of the tool call; no other regular parameters are allowed when this is used - - `[ToolParameter(Type = ToolParameterType.Injected)]`: Indicates this parameter is automatically injected by the dependency injection framework, not passed through the MCP protocol layer +### Preparing the MCP Server -Method return values can be of the following types: +1. You can prepare an stdio transport MCP server + - e.g. `npx -y @modelcontextprotocol/server-everything stdio` (no need to start it in advance) +2. Or prepare an HTTP transport MCP server + - e.g. `npx -y @modelcontextprotocol/server-everything streamableHttp` (must be started in advance) -- `string`: Represents a string returned to the AI (typically natural language that can be understood by the AI) -- `void`: Indicates no return value **Note that while this is supported by the MCP protocol, some MCP clients may throw exceptions when the MCP server returns an empty result; in such cases, it's recommended to use `string` as the return type and return an empty string** -- Any type that can be JSON serialized (according to the MCP protocol specification, **return values must be object types**, not arrays or primitive types) -- `CallToolResult`: A generic tool call result, which is the final data structure at the MCP protocol layer; using this return type, you can directly control the data returned to the AI at the MCP protocol layer -- `CallToolResult`: A tool call result with a structured data type, created via the `CallToolResult.FromResult(result)` method, where `T` is any type that can be JSON serialized; using this return type, you can control the data returned to the AI at the MCP protocol layer while still maintaining structured return value functionality - -**Notably**, when the return value is a JSON-serializable object, according to the MCP protocol specification, we return structured data and also include the JSON serialized string of this data in the plain string return value (for compatibility). Additionally, this tool will be marked as "having structured return values". - -Methods can be synchronous or asynchronous: +```powershell +npx -y @modelcontextprotocol/server-everything streamableHttp +Starting Streamable HTTP server... +MCP Streamable HTTP Server listening on port 3001 +``` -- Supports all the above synchronous return value types -- Supports `Task`, `Task`, `ValueTask`, and `ValueTask` asynchronous return values +You can also use an MCP server written with this library, such as the one created by the example code in the previous section of this guide. -### Type Polymorphism +### Initialization -Method parameters and return values can use interface or abstract class types, but all possible concrete implementation types must be annotated: +A typical MCP client program looks like this: ```csharp -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(Foo), typeDiscriminator: "foo")] -[JsonDerivedType(typeof(Bar), typeDiscriminator: "bar")] -public interface IFooBar +/// +/// A class in the MCP host program for managing MCP clients. +/// +internal class McpManager { - [JsonPropertyName("name")] - string? Name { get; init; } + public McpClient CreateMcpClient() + { + // The client name and version are sent to the server via the MCP protocol + var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + // Connect to an stdio server (no need to start it in advance) + .WithStdio("npx", ["-y", "@modelcontextprotocol/server-everything", "stdio"]) + // Or connect to an HTTP server (must be started in advance) + // .WithHttp("http://localhost:3001/mcp") + // Per official MCP protocol requirements, a single client can only connect to one MCP server + .Build(); + return mcpClient; + } } +``` -public class Foo : IFooBar -{ - public string? Name { get; init; } - - [JsonPropertyName("fooValue")] - public int FooValue { get; init; } -} +```csharp +// Optional call to ensure the client is connected to the server. If not called, the client will auto-connect on the first API call. +// The benefit of calling it early is that you can uniformly catch connection exceptions, filtering out broken MCP servers early to avoid affecting subsequent business logic. +await mcpClient.EnsureConnectedAsync(); -public class Bar : IFooBar +var tools = await mcpClient.ListToolsAsync(); +foreach (var tool in tools.Tools) { - public string? Name { get; init; } - - [JsonPropertyName("barValue")] - public string? BarValue { get; init; } + Console.WriteLine(tool.Name); } -``` -Please note that the Json serializer must be annotated with `AllowOutOfOrderMetadataProperties`, as AI may not always pass parameters in order: - -```csharp -[JsonSerializable(typeof(IFooBar))] -[JsonSourceGenerationOptions( - AllowOutOfOrderMetadataProperties = true)] -internal partial class McpToolJsonContext : JsonSerializerContext; +// Call a tool, passing the tool name and arguments. If AOT is enabled, the arguments can use a JsonElement generated from your own JsonSerializerContext. +var result = await mcpClient.CallToolAsync("echo", JsonSerializer.SerializeToElement(new { text = "Hello, World!" })); +Console.WriteLine(result.Content); ``` + +> A single client connects to only one MCP server. In agent programs, you typically need to manage multiple MCP servers simultaneously. See [McpServerManager](McpServerManager.md). diff --git a/docs/en/README.md b/docs/en/README.md new file mode 100644 index 0000000..b7f226c --- /dev/null +++ b/docs/en/README.md @@ -0,0 +1,20 @@ +# DotNetCampus.ModelContextProtocol + +- Quick Start + - [Quick Start](QuickStart.md) +- MCP Tools & Resources + - [Tools](Tools.md) + - [Resources](Resources.md) + - [Prompts](Prompts.md) + - [Roots](Roots.md) + - [Sampling](Sampling.md) +- Agent Integration + - [McpServerManager](McpServerManager.md) +- MCP Mechanisms + - [Authorization](Authorization.md) + - [Elicitation](Elicitation.md) + - [Utilities](Utilities.md) + - [_meta](Meta.md) + - [Dependency Injection](DependencyInjection.md) +- MCP Transport + - [Choosing a Transport](Transport.md) diff --git a/docs/en/Resources.md b/docs/en/Resources.md new file mode 100644 index 0000000..02c3648 --- /dev/null +++ b/docs/en/Resources.md @@ -0,0 +1,165 @@ +# Resources + +Resources allow an MCP server to expose contextual data to clients. Resources are typically read-only, such as file contents, configuration, database schemas, images, or runtime state. + +We assume you have already completed the MCP server and client setup described in [Quick Start](QuickStart.md) before reading this guide. + +## Server-Side Resource Provision + +### Initialization + +Resources must be registered with the MCP server via `WithResources`: + +```csharp +internal class Program +{ + private static async Task Main(string[] args) + { + var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithResources(r => r + // Register various MCP resources + .WithResource(() => new SampleResources()) + ) + .WithLocalHostHttp(5943, "mcp") + .Build(); + + await mcpServer.RunAsync(); + } +} +``` + +### MCP Resource Method Declaration + +A typical MCP resource implementation looks like this: + +```csharp +public class SampleResources +{ + /// + /// A text resource with a fixed URI. + /// + [McpServerResource( + UriTemplate = "sample://welcome", + Name = "Welcome Text", + Description = "A welcome text message")] + public string WelcomeText() + { + return "Hello from MCP Resources."; + } + + /// + /// A JSON resource with a URI template parameter. + /// + /// The current resource read context. + /// The user ID. + /// A user profile in JSON. + [McpServerResource( + UriTemplate = "sample://users/{userId}/profile", + Name = "User Profile", + MimeType = "application/json")] + public TextResourceContents UserProfile(IMcpServerReadResourceContext context, int userId) + { + if (userId <= 0) + { + throw new McpResourceNotFoundException(context); + } + + return new TextResourceContents + { + Uri = context.Uri, + MimeType = "application/json", + Text = $$"""{"userId":{{userId}},"name":"User {{userId}}"}""" + }; + } + + /// + /// A binary resource. Binary content must be Base64-encoded. + /// + [McpServerResource( + UriTemplate = "sample://hello.bin", + Name = "Hello Binary", + MimeType = "application/octet-stream")] + public BlobResourceContents HelloBinary() + { + var bytes = System.Text.Encoding.UTF8.GetBytes("Hello from binary resource."); + return new BlobResourceContents + { + Uri = "sample://hello.bin", + MimeType = "application/octet-stream", + Blob = Convert.ToBase64String(bytes) + }; + } +} +``` + +In this example: + +- `UriTemplate` is the URI or URI template used by clients when reading the resource +- Fixed URI resources appear in the `resources/list` result +- Resources with template parameters like `{userId}` appear in the `resources/templates/list` result, and clients can read them using the actual URI +- If a resource does not exist, you can throw `McpResourceNotFoundException` + +Resource methods can return the following types: + +- `string`: A text resource +- `ResourceContents`: A single resource content, such as `TextResourceContents` or `BlobResourceContents` +- `IReadOnlyList`: Multiple resource contents returned at once +- `ReadResourceResult`: Direct control over the resource read result at the MCP protocol layer + +If a resource method needs to read the current request URI, `_meta` metadata, or needs to throw `McpResourceNotFoundException` when a resource is not found, declare `IMcpServerReadResourceContext` as a parameter. Metadata from the client request (e.g. TraceId for distributed tracing) can be accessed via `context.Meta`. + +## Client-Side Resource Reading + +Typical code for an MCP client reading resources: + +```csharp +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp("http://localhost:5943/mcp") + .Build(); + +// List fixed URI resources. +var resources = await mcpClient.ListResourcesAsync(); +foreach (var resource in resources.Resources) +{ + Console.WriteLine($"{resource.Uri} {resource.MimeType}"); +} + +// Read a fixed URI resource. +var welcome = await mcpClient.ReadResourceAsync("sample://welcome"); +Console.WriteLine(welcome.Contents.OfType().FirstOrDefault()?.Text); + +// Read a template URI resource. +var profile = await mcpClient.ReadResourceAsync("sample://users/42/profile"); +Console.WriteLine(profile.Contents.OfType().FirstOrDefault()?.Text); + +// Read a binary resource. +var binary = await mcpClient.ReadResourceAsync("sample://hello.bin"); +var blob = binary.Contents.OfType().FirstOrDefault(); +if (blob is not null) +{ + var bytes = Convert.FromBase64String(blob.Blob); + Console.WriteLine($"Blob bytes: {bytes.Length}"); +} +``` + +If you need to handle all resource contents uniformly, dispatch by resource content type: + +```csharp +var result = await mcpClient.ReadResourceAsync("sample://users/42/profile"); +foreach (var content in result.Contents) +{ + switch (content) + { + case TextResourceContents text: + Console.WriteLine(text.Text); + break; + + case BlobResourceContents blob: + var bytes = Convert.FromBase64String(blob.Blob); + Console.WriteLine($"Blob bytes: {bytes.Length}"); + break; + } +} +``` + +> In agent programs, you typically need to manage multiple MCP servers simultaneously. For a complete MCP server manager example, see [McpServerManager](McpServerManager.md). diff --git a/docs/en/Roots.md b/docs/en/Roots.md new file mode 100644 index 0000000..32358bf --- /dev/null +++ b/docs/en/Roots.md @@ -0,0 +1,5 @@ +# Roots + +MCP Roots allows the client to declare the currently accessible workspace root directories to the server. + +> The current version has not yet implemented Roots (planned). diff --git a/docs/en/Sampling.md b/docs/en/Sampling.md new file mode 100644 index 0000000..ad7ffdf --- /dev/null +++ b/docs/en/Sampling.md @@ -0,0 +1,140 @@ +# Sampling + +Sampling allows server-side tools to send `sampling/createMessage` requests to the client during execution. The client decides whether to call a model, which model to call, and whether to return the result to the server. + +We assume you have already completed the MCP server and client setup described in [Quick Start](QuickStart.md) before reading this guide. + +## Server-Side Initiation of Sampling Requests + +Sampling is typically written within tool methods. The tool method initiates requests through `IMcpServerCallToolContext.Sampling`: + +```csharp +public class SamplingTools +{ + /// + /// Answers a question using the client's large language model. + /// + /// The current tool call context. + /// The question to send to the client-side model. + /// The text returned by the client-side model. + [McpServerTool] + public async Task AskLlm(IMcpServerCallToolContext context, string question) + { + if (!context.Sampling.IsSupported) + { + return "The current client has not declared Sampling capability."; + } + + var result = await context.Sampling.CreateMessageAsync( + question, + maxTokens: 1024, + cancellationToken: context.CancellationToken); + + return result.Content is TextContentBlock text ? text.Text : string.Empty; + } +} +``` + +Simply register the tool when initializing the MCP server: + +```csharp +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithTools(t => t + .WithTool(() => new SamplingTools()) + ) + .WithLocalHostHttp(5943, "mcp") + .Build(); +``` + +## Client-Side Handling of Sampling Requests + +The client declares its Sampling support via `WithSamplingHandler` and handles `sampling/createMessage` requests sent by the server. + +### Simple Overload + +Pass the handler function directly: + +```csharp +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp("http://localhost:5943/mcp") + .WithSamplingHandler(async (request, cancellationToken) => + { + // In a real application, you typically need to: + // 1. Let the user confirm whether to allow this Sampling request; + // 2. Call your own large language model based on request.Messages; + // 3. Let the user confirm whether to allow returning the result to the server. + var prompt = request.Messages + .Select(x => x.Content) + .OfType() + .FirstOrDefault() + ?.Text ?? string.Empty; + + await Task.Yield(); + return new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = $"Client model received: {prompt}" }, + Model = "demo-model", + StopReason = "endTurn", + }; + }) + .Build(); +``` + +### Factory Overload (Dependency Injection Scenarios) + +When the handler function depends on services from `IServiceProvider`, use the factory overload: + +```csharp +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + .WithServices(serviceProvider) // See [Dependency Injection](DependencyInjection.md) + .WithHttp("http://localhost:5943/mcp") + .WithSamplingHandler(services => + { + var llmClient = services!.GetRequiredService(); + var userConsent = services.GetRequiredService(); + + return async (request, cancellationToken) => + { + // 1. Request user consent + if (!await userConsent.RequestSamplingConsentAsync(request, cancellationToken)) + { + return CreateMessageResult.FromRefusal("User rejected the Sampling request."); + } + + // 2. Call your own large language model + var prompt = request.Messages + .Select(x => x.Content) + .OfType() + .FirstOrDefault() + ?.Text ?? string.Empty; + var response = await llmClient.GenerateAsync(prompt, cancellationToken); + + // 3. Let the user confirm whether to allow returning the result + if (!await userConsent.RequestResultConsentAsync(response, cancellationToken)) + { + return CreateMessageResult.FromRefusal("User rejected returning the Sampling result."); + } + + return new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = response }, + Model = response.Model, + StopReason = "endTurn", + }; + }; + }) + .Build(); +``` + +Then call server-side tools just like normal tools: + +```csharp +var arguments = JsonSerializer.SerializeToElement(new { question = "Please introduce MCP in one sentence." }); +var result = await mcpClient.CallToolAsync("ask_llm", arguments); + +Console.WriteLine(result.Content); +``` + +If the client has not called `WithSamplingHandler`, `context.Sampling.IsSupported` in the server-side tool will return `false`. diff --git a/docs/en/Tools.md b/docs/en/Tools.md new file mode 100644 index 0000000..26845c9 --- /dev/null +++ b/docs/en/Tools.md @@ -0,0 +1,421 @@ +# Tools + +Tools allow clients to request the server to perform actions. We assume you have already completed the MCP server and client setup described in [Quick Start](QuickStart.md) before reading this guide. + +## Server-Side Tool Implementation + +### Basic Example + +A simple MCP tool implementation looks like this: + +```csharp +public class SampleTools +{ + /// + /// A debugging tool for AI that echoes back some information as-is. + /// + /// The string to echo back. + /// The echoed string. + [McpServerTool(ReadOnly = true)] + public string EchoTool(string text) + { + return text; + } +} +``` + +In this example: + +- XML comments become an important part of the tool's description and are sent to the client via the MCP protocol, so writing good comments is critical for the LLM to correctly use the tool. Also, you don't need to worry about multi-language comments — LLMs don't care which language you use to describe the tool. +- If parameters are complex data types or enums, you don't need to describe every internal field or property in detail within the parameter comments, because this library automatically extracts comments recursively and includes them in the MCP protocol when sending to the client. +- Tool names use snake_case by default, derived from the method name. In this example, you would get `echo_tool`. + +### Custom Tool Attributes + +The `[McpServerTool]` attribute supports several properties that give you fine-grained control over the tool's behavior and metadata in the MCP protocol: + +```csharp +/// +/// A debugging tool for AI that echoes back some information as-is. +/// +/// The string to echo back. +[McpServerTool( + Name = "echo_tool", // Override the tool name to decouple from the method name (e.g. to avoid Async suffix affecting the tool name) + Title = "Echo Output", // Human-readable tool title, invisible to AI; can be used for UI display + Description = "A debugging tool for AI that echoes back some information as-is.", // Override the description from method XML comments + Idempotent = true, // Mark as idempotent; clients can safely retry calls + OpenWorld = false, // Mark that this tool does not interact with the external open world + ReadOnly = true // Mark as read-only; calling it does not modify its environment +)] +public string EchoCustomized(string text) +{ + return text; +} +``` + +Property descriptions: + +- **Name**: The tool name in the MCP protocol. Uses the method's snake_case name if not specified. +- **Title**: A human-readable tool title, invisible to AI; can be used for UI display. +- **Description**: The tool description, overriding the description in the method's XML comments. +- **Idempotent**: Whether the tool is idempotent. Idempotent tools can be safely retried by the client on network errors. +- **OpenWorld**: Whether the tool interacts with the external open world (e.g. calling a web API). +- **ReadOnly**: Whether the tool is read-only. Read-only tools do not modify their environment when called. + +### Parameters and Context + +#### Implicit Parameter Types + +Tool methods can receive the following implicit parameters — just add them to the method signature as needed: + +- Any JSON-deserializable type (primitives, arrays, objects, etc.) — passed in by the MCP client +- `CancellationToken` — triggered when the client cancels the tool call; recommended to always declare as the last parameter +- `IMcpServerCallToolContext` — provides contextual information about the current tool call +- `JsonElement` — receives arbitrary JSON data; suitable for scenarios where the parameter structure is uncertain + +#### Explicit Parameter Types (`[ToolParameter]` Attribute) + +Use the `[ToolParameter]` attribute to mark special parameter behavior: + +- `[ToolParameter(Type = ToolParameterType.InputObject)]`: This parameter receives the entire input object of the tool call (deserialized into a type). After using this attribute, **no other** plain parameters are allowed. +- `[ToolParameter(Type = ToolParameterType.Injected)]`: This parameter is automatically injected by the dependency injection framework, not passed through the MCP protocol layer. Requires `IServiceProvider` to be configured during server initialization. See [Dependency Injection](DependencyInjection.md) for details. + +#### IMcpServerCallToolContext + +`IMcpServerCallToolContext` provides contextual information during tool method execution, including: + +- Current tool name (`context.Name`) +- Raw JSON input arguments (`context.InputJsonArguments`) +- `_meta` metadata from the request (`context.Meta`), useful for distributed tracing +- MCP server information (`context.McpServer.ServerName`) +- HTTP transport layer context (`context.HttpTransportContext`, including SessionId, Headers, etc.) + +> **Important**: The `IMcpServerCallToolContext` instance is **only valid during the current tool method execution**. Do not store it in static fields or pass it across async boundaries, as the context becomes invalid once the tool call completes. + +#### Full Parameter Example + +The following example demonstrates combined usage of `IMcpServerCallToolContext`, parameters with default values, and nullable parameters: + +```csharp +/// +/// A debugging tool for AI that echoes back some information as-is. +/// +/// The string to echo back. +/// How to return the string. +/// The number of times to return the string. +/// Meaningless extra data. +[McpServerTool(Name = "echo_tool")] +public Task EchoAsync( + IMcpServerCallToolContext context, + string text, + EchoOptions options = EchoOptions.JsonObject, + int count = 1, + EchoExtraData? extraData = null) +{ + var info = $""" + Server name: {context.McpServer.ServerName} + SessionId: {context.HttpTransportContext?.SessionId} + Headers: {string.Join(", ", context.HttpTransportContext?.Headers)} + InputJsonArguments: {context.InputJsonArguments} + """; + var result = $""" + Echoing text: {text} + Options: {options} + Count: {count} + ExtraData: {extraData} + """; + return Task.FromResult(new EchoResult { Info = info, Result = result }); +} +``` + +(The definitions of `EchoOptions`, `EchoExtraData`, and `EchoResult` used in this example can be found in [Auxiliary Types](#auxiliary-types) below.) + +### Return Value Types + +The method return value can be one of the following types: + +- `string`: A string returned to the AI (typically natural language understandable by AI) +- `void`: No return value. **Note**: Although this is a type supported by the MCP protocol, some MCP clients may error when the server returns an empty result. In such cases, consider returning a `string` with an empty value instead. +- Any JSON-serializable type (per MCP protocol specification, the **return value must be an object type**, not an array or primitive) +- `CallToolResult`: A generic tool call result — the final data structure of the MCP protocol layer. Using this return type allows you to directly control the data returned to the AI at the protocol level. +- `CallToolResult`: A tool call result with a structured data type, created via `CallToolResult.FromResult(result)`. `T` is any JSON-serializable type. Using this return type gives you structured return value capabilities while retaining protocol-level control over the returned data. + +**Notably**, when the return value is a JSON-serializable object, per the MCP protocol specification we return structured data and also include the JSON-serialized string in the plain text return value (for compatibility). The tool will also be marked as "having structured return values". + +**Notably**, the MCP protocol specification does not allow collection types as return values. The analyzer will check whether an MCP tool has a collection return value, and if so, will report a DM0101 error. + +### Synchronous vs. Asynchronous + +Methods can be synchronous or asynchronous: + +- Synchronous: Supports all of the above return value types +- Asynchronous: Supports `Task`, `Task`, `ValueTask`, and `ValueTask` async return types + +### How Tools Report Errors + +Tools can report errors to the client in two ways. Choose based on your scenario: + +#### Method 1: Throw `McpToolUsageException` + +Suitable for "the user used this tool incorrectly" scenarios, such as invalid parameters. Throwing this automatically returns `isError: true` to the client through the MCP protocol layer — no need to change the return value type: + +```csharp +[McpServerTool] +public string Echo(string text) +{ + if (string.IsNullOrWhiteSpace(text)) + { + throw new McpToolUsageException("The text parameter cannot be empty."); + } + return text; +} +``` + +#### Method 2: Return `CallToolResult.FromError()` + +Suitable for scenarios requiring precise control over the returned content or structured error information: + +```csharp +[McpServerTool] +public CallToolResult SafeEcho(string text) +{ + if (string.IsNullOrWhiteSpace(text)) + { + return CallToolResult.FromError("The text parameter cannot be empty."); + } + return text; +} +``` + +Differences between the two approaches: + +| | `McpToolUsageException` | `CallToolResult.FromError()` | +|---|---|---| +| Return value type | Any type (no signature change) | Must be `CallToolResult` | +| Use case | Fail fast, no further execution | Structured error info or precise control over return format | +| Advantage | Simple and direct, minimal code changes | Maximum flexibility | + +### Auxiliary Types + +The following are the auxiliary type definitions used in the complex examples above: + +```csharp +/// +/// How to return the string. +/// +public enum EchoOptions +{ + /// + /// Return as plain text. + /// + PlainText, + + /// + /// Return as a JSON object. + /// + JsonObject, +} + +/// +/// Meaningless extra data. +/// +/// The first storable value. +public record EchoExtraData(string Data1) +{ + /// + /// The second storable value. + /// + public string Data2 { get; init; } = ""; +} + +/// +/// Tool return value for AI debugging use. +/// +public record EchoResult +{ + /// + /// Context information for AI debugging use. + /// + public string Info { get; init; } = ""; + + /// + /// Result information for AI debugging use. + /// + public string Result { get; init; } = ""; +} +``` + +## Server-Side Advanced Initialization + +### JSON Serialization and Dependency Injection + +When your tool parameters or return values use custom types, you need to provide a JSON serialization context to support AOT compilation. If you want your tool classes to support dependency injection, provide an `IServiceProvider` instance. The MCP library's DI is implemented by compile-time source generators with zero reflection. See [Dependency Injection](DependencyInjection.md) for details. + +```csharp +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + // Provide JSON serialization context (AOT-compatible) + .WithJsonSerializer(McpToolJsonContext.Default) + + // Provide IServiceProvider to support constructor injection in tool classes + // and [ToolParameter(Type = ToolParameterType.Injected)] injection in tool method parameters + .WithServices(appServiceProvider) + + .WithTools(t => t + // Plain registration: new instance created per call + .WithTool(() => new SampleTools()) + // DI registration: lifecycle managed by IServiceProvider (requires WithServices to be configured) + .WithTool() + ) + + .WithLocalHostHttp(5943, "mcp") + .Build(); +``` + +### Logging Integration + +Bridge the MCP server's internal logs to your own logging system to monitor the server's health: + +```csharp +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + // The second parameter controls the log detail level for raw transport-layer messages; default is no logging + .WithLogger(new McpLoggerBridge(myLogger), McpTransportRawMessageLoggingDetailLevel.Trimmed) + // ... other configuration + .Build(); +``` + +Logger bridge implementation reference: + +```csharp +internal class McpLoggerBridge(ILogger logger) : IMcpLogger +{ + public bool IsEnabled(LoggingLevel loggingLevel) + { + return logger.IsEnabled(loggingLevel.ToLogLevel()); + } + + public void Log(LoggingLevel loggingLevel, TState state, Exception? exception, + Func formatter) + { + logger.Log(loggingLevel.ToLogLevel(), default, state, exception, formatter); + } +} +``` + +### Request Interception + +By inheriting from `McpServerRequestHandlers` and overriding methods, you can intercept all requests sent to this MCP server for unified processing: + +```csharp +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithRequestHandlers(s => new CustomRequestHandlers(s)) + // ... other configuration + .Build(); +``` + +Interceptor implementation reference: + +```csharp +internal class CustomRequestHandlers(McpServer server) : McpServerRequestHandlers(server) +{ + public override async ValueTask CallToolAsync( + RequestContext rawRequest, + string? toolName, IMcpServerTool? tool, IMcpServerCallToolContext? context) + { + var result = await base.CallToolAsync(rawRequest, toolName, tool, context); + if (result.RawException is { } exception) + { + // Perform additional logging or alerting when a tool call throws an exception + Log.Error("Tool call exception", exception); + } + return result; + } +} +``` + +### Transport Layer Selection + +This library supports multiple transport layers. Choose based on your deployment scenario: + +| Transport | Method | Use Case | +|--------|------|---------| +| Streamable HTTP (built-in) | `.WithLocalHostHttp()` | Local communication, lightweight, zero dependencies | +| Streamable HTTP (TouchSocket) | `.WithTouchSocketHttp()` | Public network listening, high-performance HTTP | +| stdio | `.WithStdio()` | Standard input/output, officially recommended by MCP | +| dotnetCampus.Ipc | `.WithDotNetCampusIpc()` | Local high-performance IPC | + +For detailed instructions and configuration, see [Choosing a Transport Layer](Transport.md). + +## Client-Side Tool Invocation + +### Client Builder Overloads + +`McpClientBuilder`'s `WithHttp` and `WithStdio` methods each have two overloads: the simple overload is suitable for quick experimentation, while the options overload is suitable for production scenarios requiring custom configuration. + +**HTTP transport:** + +```csharp +// Simple overload: specify URL only +var client = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp("http://localhost:3001/mcp") + .Build(); + +// Options overload: configure custom HttpClient, timeouts, etc. +var client = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp(new HttpClientTransportOptions + { + ServerUrl = "http://localhost:3001/mcp", + HttpClient = customHttpClient, // Can inject an HttpClient with authentication headers + }) + .Build(); +``` + +**stdio transport:** + +```csharp +// Simple overload: specify command and arguments +var client = new McpClientBuilder("Example Client", "1.0.0") + .WithStdio("npx", ["-y", "@modelcontextprotocol/server-everything", "stdio"]) + .Build(); + +// Options overload: configure environment variables +var client = new McpClientBuilder("Example Client", "1.0.0") + .WithStdio(new StdioClientTransportOptions + { + Command = "python", + Arguments = ["-m", "my_mcp_server"], + EnvironmentVariables = new Dictionary + { + ["PYTHONPATH"] = "/path/to/modules", + }, + }) + .Build(); +``` + +### Basic Invocation + +A typical invocation flow for a single MCP client: + +```csharp +var client = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp("http://localhost:5943/mcp") + .Build(); + +// Optional: connect early to catch exceptions uniformly +await client.EnsureConnectedAsync(); + +// List tools +var tools = await client.ListToolsAsync(); +foreach (var tool in tools.Tools) +{ + Console.WriteLine($"{tool.Name}: {tool.Description}"); +} + +// Call a tool +var arguments = JsonSerializer.SerializeToElement(new { text = "Hello" }); +var result = await client.CallToolAsync("echo_tool", arguments); +Console.WriteLine(result.Content); +``` + +### Multi-Server Management (Agent Scenarios) + +In agent programs, you typically need to manage multiple MCP servers simultaneously (built-in tools, external services, position programs, etc.). For a complete MCP server manager example, see [McpServerManager](McpServerManager.md). diff --git a/docs/en/Transport.md b/docs/en/Transport.md new file mode 100644 index 0000000..c7ca9d6 --- /dev/null +++ b/docs/en/Transport.md @@ -0,0 +1,406 @@ +# Transport + +The MCP transport layer is responsible only for sending and receiving JSON-RPC messages. Business code usually only needs to select the same transport layer on both the server and client sides. + +## Overview + +| Transport | Built-in | Listen Address | Use Case | +|--------------------|----------|--------------------------|-----------------------------------| +| HTTP (built-in) | ✅ | `localhost` only | Local development, single-machine | +| TouchSocket HTTP | ❌ ext. | `0.0.0.0` etc. supported | LAN/public network | +| stdio | ✅ | - | Client launches server process | +| In-Process | ✅ | - | Same-process embedding, testing | +| IPC | ❌ ext. | - | Cross-process on same machine | + +> The core library's built-in HTTP transport only listens on the loopback address, for security and to minimize dependencies. If you need to listen on non-loopback addresses like `0.0.0.0`, use [TouchSocket HTTP](#touchsocket-http-extension). If you need the IPC transport, use [IPC](#ipc). See [Two Ways to Obtain Extended Transports](#two-ways-to-obtain-extended-transports) for how to get them. + +--- + +We assume you have already completed the MCP server and client setup described in [Quick Start](QuickStart.md) before reading this guide. + +> **Core Principle**: `McpClientBuilder.Build()` only creates a client object and **does not trigger any I/O or network connections**. Connections are lazily triggered by `EnsureConnectedAsync` on the first API call. For details, see [Client "Build Before Connect" Principle](../knowledge/client-build-before-connect.md). + +## HTTP + +[Quick Start](QuickStart.md) uses the Streamable HTTP transport layer. + +Server: + +```csharp +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + // Listen on http://localhost:5943/mcp + .WithLocalHostHttp(5943, "mcp") + .Build(); +``` + +Client: + +```csharp +// Simple overload: specify URL only +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp("http://localhost:5943/mcp") + .Build(); + +// Options overload: configure custom HttpClient (auth headers, proxy, etc.) +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp(new HttpClientTransportOptions + { + ServerUrl = "http://localhost:5943/mcp", + HttpClient = customHttpClient, + }) + .Build(); +``` + +> The built-in HTTP server transport (`LocalHostHttpServerTransport`) only listens on `127.0.0.1` and `[::1]`. If you need to listen on other addresses (such as `0.0.0.0`), see the [TouchSocket HTTP (Extension)](#touchsocket-http-extension) section below. + +--- + +## TouchSocket HTTP (Extension) + +The core library's built-in HTTP transport only listens on the local loopback address. If you need to listen on non-loopback addresses like `0.0.0.0` (e.g., when deploying to a LAN or the public internet), use the TouchSocket HTTP transport. + +There are two ways to obtain the TouchSocket HTTP transport; see [Two Ways to Obtain Extended Transports](#two-ways-to-obtain-extended-transports) for details. The following examples assume you have obtained TouchSocket HTTP transport support through either method: + +### Server + +```csharp +// Simple overload: listen on localhost +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithTouchSocketHttp(5943, "mcp") + .Build(); + +// Listen on 0.0.0.0 (all network interfaces, including LAN and public) +var mcpServer = new McpServerBuilder("Public Server", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithTouchSocketHttp(["0.0.0.0:5943", "[::]:5943"], "mcp") + .Build(); + +// Options overload: configure all parameters +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithTools(t => t.WithTool(() => new SampleTools())) + .WithTouchSocketHttp(new TouchSocketHttpServerTransportOptions + { + Listen = ["0.0.0.0:5943", "[::]:5943"], + EndPoint = "mcp", + }) + .Build(); +``` + +The `Listen` list uses the `"IP:port"` format. Only IP addresses are allowed — domain names cannot be used. Multiple addresses and ports can be listened on simultaneously. + +### Reuse an Existing HttpService + +If you already have a running `HttpService` (the core type of TouchSocket), you can attach the MCP server as a plugin: + +```csharp +// httpService is your existing HttpService instance +httpService.UseMcpServer("Example Server", "1.0.0", builder => +{ + builder.WithTools(t => t.WithTool(() => new SampleTools())); +}); + +// You can also specify a custom endpoint +httpService.UseMcpServer("Example Server", "1.0.0", "/custom-mcp", builder => +{ + builder.WithTools(t => t.WithTool(() => new SampleTools())); +}); +``` + +> `HttpService` implements the `IPluginManager` interface. `UseMcpServer` is an extension method on `IPluginManager`. + +### Client + +The TouchSocket HTTP transport client requires no special handling — the client simply makes HTTP requests to the server and can directly reuse the core library's HTTP client transport: + +```csharp +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + .WithHttp("http://192.168.1.100:5943/mcp") + .Build(); +``` + +--- + +## stdio + +stdio is suitable for scenarios where the client starts the server process, and is the transport layer that the MCP specification recommends servers support. + +Server: + +```csharp +var mcpServer = new McpServerBuilder("Example Server", "1.0.0") + .WithTools(tools => tools.WithTool(() => new SampleTools())) + // Send and receive MCP messages through standard input/output + .WithStdio() + .Build(); + +await mcpServer.RunAsync(); +``` + +Client: + +```csharp +// Simple overload: specify command and arguments +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + // The client will start this command and communicate through its standard input/output + .WithStdio("dotnet", ["run", "--project", "../MinimalMcpServer"]) + .Build(); + +// Options overload: configure environment variables +var mcpClient = new McpClientBuilder("Example Client", "1.0.0") + .WithStdio(new StdioClientTransportOptions + { + Command = "dotnet", + Arguments = ["run", "--project", "../MinimalMcpServer"], + EnvironmentVariables = new Dictionary + { + ["DOTNET_ENVIRONMENT"] = "Production", + }, + }) + .Build(); +``` + +## In-Process + +In-Process is suitable for same-process embedding and integration testing. The server must be started first, and the client establishes a connection on the first request. + +```csharp +var mcpServer = new McpServerBuilder("Embedded Server", "1.0.0") + .WithTools(tools => tools.WithTool(() => new SampleTools())) + // Allow in-process MCP client connections + .WithInProcess() + .Build(); + +await mcpServer.StartAsync(); +try +{ + await using var mcpClient = new McpClientBuilder("Embedded Client", "1.0.0") + .WithInProcess(mcpServer) + .Build(); + + var arguments = JsonSerializer.SerializeToElement(new { text = "Hello" }); + var result = await mcpClient.CallToolAsync("echo_tool", arguments); + + Console.WriteLine(result.Content); +} +finally +{ + await mcpServer.StopAsync(); +} +``` + +The In-Process transport layer does not provide process isolation — the server and client run in the same process with the same permissions. It is suitable for embedded scenarios and integration testing within trusted boundaries. + +> For the definition of `SampleTools`, see [Tools - Basic Example](Tools.md#basic-example). + +## IPC + +The IPC transport layer is suitable for communication between different processes on the same machine, based on named pipes provided by dotnetCampus.Ipc. + +There are two ways to obtain the IPC transport; see [Two Ways to Obtain Extended Transports](#two-ways-to-obtain-extended-transports) for details. The following examples assume you have obtained IPC transport support through either method: + +### Server + +```csharp +var mcpServer = new McpServerBuilder("IPC Example Server", "1.0.0") + .WithTools(tools => tools.WithTool(() => new SampleTools())) + // sample-mcp-pipe is the local pipe name; clients must use the same name to connect + .WithDotNetCampusIpc("sample-mcp-pipe") + .Build(); + +await mcpServer.RunAsync(); +``` + +You can also reuse an externally created `IpcProvider`: + +```csharp +var mcpServer = new McpServerBuilder("IPC Example Server", "1.0.0") + .WithTools(tools => tools.WithTool(() => new SampleTools())) + .WithDotNetCampusIpc(existingIpcProvider) + .Build(); +``` + +### Client + +```csharp +await using var mcpClient = new McpClientBuilder("IPC Example Client", "1.0.0") + .WithDotNetCampusIpc("sample-mcp-pipe") + .Build(); +``` + +You can also reuse an externally created `IpcProvider`: + +```csharp +await using var mcpClient = new McpClientBuilder("IPC Example Client", "1.0.0") + .WithDotNetCampusIpc(existingIpcProvider, "sample-mcp-pipe") + .Build(); +``` + +--- + +## Two Ways to Obtain Extended Transports + +The core library only includes three built-in transports: HTTP (localhost), stdio, and In-Process. If you need the TouchSocket HTTP or IPC transport, you can obtain them in one of two ways: + +### Method 1: Install Extension Packages (Recommended) + +Install the corresponding extension NuGet packages directly: + +```bash +# TouchSocket HTTP transport +dotnet add package DotNetCampus.ModelContextProtocol.TouchSocket.Http + +# IPC transport +dotnet add package DotNetCampus.ModelContextProtocol.Ipc +``` + +Once installed, you can directly use extension methods such as `.WithTouchSocketHttp()` / `.WithDotNetCampusIpc()`. The extension packages already pull in the underlying dependencies (`TouchSocket.Http` / `dotnetCampus.Ipc`) — this is the simplest approach. + +### Method 2: Install Underlying Libraries + Enable Source Generator + +If you want to minimize the number of `.dll` files pulled into your project (I just prefer to do it this way), you can skip the extension packages, install the underlying libraries directly, and enable the source generator to automatically generate the transport code: + +```bash +# Install the underlying libraries (not the extension packages) +dotnet add package dotnetCampus.Ipc +dotnet add package TouchSocket.Http +``` + +Then enable the source generator in your project's `.csproj`: + +```xml + + true + +``` + +With this option enabled, the analyzer (Analyzer) bundled with the `DotNetCampus.ModelContextProtocol` core package will automatically scan the libraries installed in your project: + +| Detected Library | Auto-Generated Transport | +|---------------------|-----------------------------| +| `dotnetCampus.Ipc` | IPC transport | +| `TouchSocket.Http` | TouchSocket HTTP transport | + +**Libraries that are not installed will not generate any code** — the source generator is safe and will not pollute your project. + +> **Design Philosophy**: The dotnet-campus organization favors keeping the core library zero-dependency and lightweight. IPC and TouchSocket HTTP are optional extended transports that are not forcibly bundled into the core library. Developers can pick and choose the transports they need without being forced to pull in unnecessary dependencies. + +--- + +## Custom Transport Layer + +A client transport implements `IClientTransport` and uses `IClientTransportManager` for JSON-RPC serialization and response dispatching. + +```csharp +public sealed class MyClientTransport(IClientTransportManager manager) : IClientTransport +{ + public ValueTask ConnectAsync(CancellationToken cancellationToken = default) + { + // Connect to your underlying channel here. + return ValueTask.CompletedTask; + } + + public ValueTask DisconnectAsync(CancellationToken cancellationToken = default) + { + // Close your underlying channel here. + return ValueTask.CompletedTask; + } + + public async ValueTask SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) + { + // Serialize the JSON-RPC message to a string and send it to the underlying channel. + var line = manager.WriteMessageAsync(message); + await SendLineAsync(line, cancellationToken); + } + + public ValueTask DisposeAsync() => DisconnectAsync(); + + private async Task OnLineReceivedAsync(string line, CancellationToken cancellationToken) + { + // After receiving a server message on the underlying channel, deserialize and hand it back to the MCP client for processing. + var message = await manager.ReadMessageAsync(line); + switch (message) + { + case JsonRpcResponse response: + await manager.HandleRespondAsync(response, cancellationToken); + break; + + case JsonRpcRequest request: + await manager.HandleServerRequestAsync(request, cancellationToken); + break; + } + } + + private static ValueTask SendLineAsync(string line, CancellationToken cancellationToken) + { + // Write the line to your underlying channel. + return ValueTask.CompletedTask; + } +} +``` + +Register with the client: + +```csharp +var mcpClient = new McpClientBuilder("Custom Transport Client", "1.0.0") + .WithTransport(manager => new MyClientTransport(manager)) + .Build(); +``` + +A server transport implements `IServerTransport`: + +```csharp +public sealed class MyServerTransport(IServerTransportManager manager) : IServerTransport +{ + public Task StartAsync(CancellationToken startingCancellationToken, CancellationToken runningCancellationToken) + { + // The first Task completes when the transport has started; the second Task completes when the transport stops. + return Task.FromResult(RunAsync(runningCancellationToken)); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + private async Task OnLineReceivedAsync(string line, Stream responseStream, CancellationToken cancellationToken) + { + var message = await manager.ReadMessageAsync(line); + if (message is not JsonRpcRequest request) + { + return; + } + + // Hand the client request to the MCP server for processing. + var response = await manager.HandleRequestAsync(request, cancellationToken: cancellationToken); + if (response is not null) + { + await manager.WriteMessageAsync(responseStream, response, cancellationToken); + } + } + + private static async Task RunAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } +} +``` + +Register with the server: + +```csharp +var mcpServer = new McpServerBuilder("Custom Transport Server", "1.0.0") + .WithTransport(manager => new MyServerTransport(manager)) + .Build(); +``` + +If your server transport needs to support the server proactively initiating requests to the client, such as Sampling, you also need to implement `IServerTransportSession` for each client connection. + +> **Construction Principle**: The client transport constructor **only saves parameters and does not perform I/O**. All connection work (starting processes, establishing network connections, etc.) is done in `ConnectAsync`, and `ConnectAsync` must be idempotent. For details, see [Client "Build Before Connect" Principle](../knowledge/client-build-before-connect.md). diff --git a/docs/en/Utilities.md b/docs/en/Utilities.md new file mode 100644 index 0000000..1e9f03e --- /dev/null +++ b/docs/en/Utilities.md @@ -0,0 +1,5 @@ +# Utilities + +MCP Utilities includes general capabilities such as Cancellation, Progress, Tasks, Completion, Logging, and Pagination. + +> In the current version, the `CancellationToken` parameter is natively supported (simply declare it in your tool method). The remaining capabilities (Progress, Tasks, Completion, Logging, Pagination) have not yet been implemented (planned). diff --git a/docs/knowledge/client-build-before-connect.md b/docs/knowledge/client-build-before-connect.md new file mode 100644 index 0000000..dd0ae35 --- /dev/null +++ b/docs/knowledge/client-build-before-connect.md @@ -0,0 +1,94 @@ +# 客户端"先创建后连接"原则 + +> 本文档描述 MCP 客户端的生命周期设计原则:`McpClientBuilder.Build()` 只创建客户端实例,不触发实际连接。连接在首次 API 调用时由 `EnsureConnectedAsync` 惰性触发。 + +## 原则 + +**所有传输层**(Stdio、HTTP、InProcess、IPC 等)都必须遵循同一生命周期: + +``` +McpClientBuilder.Build() → 仅创建对象、保存配置 +McpClient.XXXAsync() → 首次调用时触发 EnsureConnectedAsync → ConnectAsync +``` + +即:`Build()` 阶段 **禁止** 执行任何 I/O 操作、启动进程、建立网络连接或建立传输管道。 + +## 好处 + +### 1. 全异步请求模式 + +当一个应用程序需要连接多个 MCP 服务器时,可以在启动阶段一次性创建所有客户端,然后在实际使用时才按需连接: + +```csharp +// 启动阶段:快速创建所有客户端(无 I/O) +var clientA = new McpClientBuilder().WithHttp("https://server-a/mcp").Build(); +var clientB = new McpClientBuilder().WithStdio("server-b").Build(); +var clientC = new McpClientBuilder().WithInProcess(mcpServer).Build(); + +// 使用阶段:按需连接、并发请求 +var taskA = clientA.CallToolAsync("tool-a", argsA); +var taskB = clientB.CallToolAsync("tool-b", argsB); +var taskC = clientC.CallToolAsync("tool-c", argsC); +await Task.WhenAll(taskA, taskB, taskC); +``` + +如果 `Build()` 必须等待连接完成,上述代码就无法实现全异步——在发起请求之前连 `McpClient` 实例都没有。 + +### 2. InProcess 传输层可在服务器启动前创建客户端 + +```csharp +var server = new McpServerBuilder("Server", "1.0.0") + .WithInProcess() + .WithTools(t => t.WithTool(() => new MyTool())) + .Build(); + +// 在服务器启动前就创建客户端。 +var client = new McpClientBuilder() + .WithInProcess(server) + .Build(); + +// 稍后启动服务器。 +await server.StartAsync(); + +// 客户端首次 API 调用时才连接。 +var result = await client.CallToolAsync("my_tool", args); +``` + +### 3. 简化依赖注入与对象组装 + +客户端可以在 DI 容器中注册为单例或作用域服务,而不需要在注册时就等待异步连接完成。 + +## 副作用 + +1. **首次调用延迟**:第一次 API 调用会比后续调用慢,因为需要建立连接和完成 MCP 协议初始化握手。 +2. **连接错误延迟暴露**:如果服务器地址错误、进程启动失败等,错误不会在 `Build()` 时抛出,而是在首次 API 调用时才抛出。 +3. **状态不确定性**:`Build()` 返回的 `McpClient` 的 `IsConnected` 为 `false`,直到首次成功调用后才变为 `true`。 + +为降低副作用 2 的影响,`McpClient` 提供了公开的 `EnsureConnectedAsync` 方法。开发者可以在会话开始前主动调用此方法,将不可用的服务提前过滤掉,而不是等到业务请求时才发现异常。 + +`EnsureConnectedAsync` 是幂等的,多次调用不会重复连接。所有 API 方法(如 `CallToolAsync`)内部也会自动调用此方法,因此不显式调用也完全正常。 + +## 对传输层开发者的要求 + +实现新的 `IClientTransport` 时,必须遵循: + +1. **构造函数只保存参数**:构造函数(以及 `McpClientBuilder.WithTransport` 的工厂委托)中不得执行 I/O、启动进程或建立连接。 +2. **`ConnectAsync` 承担所有连接工作**:包括启动进程、建立网络连接、建立管道等。 +3. **`ConnectAsync` 幂等**:多次调用 `ConnectAsync` 应当安全,只有首次调用执行实际连接。 + +### 各传输层实现参考 + +| 传输层 | 构造阶段 | ConnectAsync 阶段 | +|-----------|----------------------------|-----------------------------| +| Stdio | 保存命令行参数 | 启动子进程、开始读写 stdin/stdout | +| HTTP | 保存 ServerUrl | 标记状态(真正连接在首次 POST 时建立) | +| InProcess | 保存 `InProcessServerTransport` 引用 | 调用 `transport.Connect()` 建立内存管道、启动消息循环 | +| IPC | 保存管道名称和配置 | 创建 `IpcProvider`、连接到服务器管道 | + +## 测试覆盖 + +以下测试确保此行为被固定: + +- `BuildBeforeConnect_CanCallAfterServerStarts`:先创建客户端,后启动服务器,验证调用成功。 +- `BuildBeforeConnect_MultipleClientsCanCallAfterServerStarts`:多个客户端先创建,服务器启动后并发调用。 +- `Connect_ThrowsWhenServerNotStarted`:服务器始终未启动,客户端首次 API 调用抛出异常。 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 1616fc5..a99d2e6 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) @@ -78,23 +81,18 @@ 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. **注册发送通道**: - * 将当前 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**: + * 按照官方规范 §2.1.6 的 SHOULD 建议,应立即发送一个包含事件 ID 和空 data 字段的 SSE 事件,以便客户端设置 `Last-Event-ID` 用于断线重连。 + * 当前实现发送一个空注释 `:\n\n` 作为简化版保活信号(不含事件 ID,不支持断线续传)。如需支持 Resumability,应改为发送带 ID 的真实事件。 +5. **保持循环**: + * 进入 `await Task.Delay(-1)` 等待,保持 SSE 连接存活(此通路用于未来扩展服务端主动推送,当前暂不发送任何业务消息)。 + * 在循环中捕获异常,如果连接断开则正常退出。 ### D. 处理 DELETE 请求 (Session Termination) @@ -121,26 +119,30 @@ * **插件机制**:继承 `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 -需要一个线程安全的 `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..f7e7ecb 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,33 @@ 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 回采样结果 +- [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/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 页面或 `