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/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 14d831e..ddacb50 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -17,5 +17,5 @@ jobs: - name: Build with dotnet run: dotnet build --configuration release - # - name: Test - # run: dotnet test --configuration release --no-build + - name: Test + run: dotnet test --configuration release --no-build diff --git a/.gitignore b/.gitignore index 37876ad..2e7c98b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser @@ -9,23 +9,49 @@ *.user *.userosscache *.sln.docstates +*.env # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs +# Mono auto generated files +mono_crash.* + # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ -x64/ -x86/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ bld/ -[Bb]in/ [Oo]bj/ +[Oo]ut/ [Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh # Visual Studio 2015/2017 cache/options directory .vs/ @@ -38,10 +64,15 @@ Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* +*.trx -# NUNIT +# NUnit *.VisualState.xml TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* # Build Results of an ATL Project [Dd]ebugPS/ @@ -55,6 +86,10 @@ BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ +.artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt # StyleCop StyleCopReport.xml @@ -66,6 +101,7 @@ StyleCopReport.xml *.ilk *.meta *.obj +*.idb *.iobj *.pch *.pdb @@ -73,6 +109,8 @@ StyleCopReport.xml *.pgc *.pgd *.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp *.sbr *.tlb *.tli @@ -81,6 +119,7 @@ StyleCopReport.xml *.tmp_proj *_wpftmp.csproj *.log +*.tlog *.vspscc *.vssscc .builds @@ -122,9 +161,6 @@ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user -# JustCode is a .NET coding add-in -.JustCode - # TeamCity is a build add-in _TeamCity* @@ -135,12 +171,18 @@ _TeamCity* .axoCover/* !.axoCover/settings.json +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* +.NCrunch_* .*crunch*.local.xml nCrunchTemp_* @@ -182,6 +224,8 @@ PublishScripts/ # NuGet Packages *.nupkg +# NuGet Symbol Packages +*.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. @@ -206,6 +250,8 @@ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx +*.appxbundle +*.appxupload # Visual Studio cache files # files ending in .cache can be ignored @@ -255,7 +301,9 @@ ServiceFabricBackup/ *.bim.layout *.bim_*.settings *.rptproj.rsuser -*- Backup*.rdl +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ @@ -276,6 +324,14 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -285,26 +341,22 @@ node_modules/ _Pvt_Extensions # Paket dependency manager -.paket/paket.exe +**/.paket/paket.exe paket-files/ # FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml +**/.fake/ # CodeRush personal settings -.cr/personal +**/.cr/personal # Python Tools for Visual Studio (PTVS) -__pycache__/ +**/__pycache__/ *.pyc # Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config +#tools/** +#!tools/packages.config # Tabs Studio *.tss @@ -326,15 +378,56 @@ ASALocalRun/ # MSBuild Binary and Structured Log *.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder -.mfractor/ +**/.mfractor/ # Local History for Visual Studio -.localhistory/ +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ # BeatPulse healthcheck temp database healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# Exclude Tools +.serena +.idea \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 63c0239..5553d08 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,11 +6,14 @@ + + + diff --git a/DotNetCampus.ModelContextProtocol.sln b/DotNetCampus.ModelContextProtocol.sln index a01fcac..67d949e 100644 --- a/DotNetCampus.ModelContextProtocol.sln +++ b/DotNetCampus.ModelContextProtocol.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11819.183 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.ModelContextProtocol", "src\DotNetCampus.ModelContextProtocol\DotNetCampus.ModelContextProtocol.csproj", "{B433131D-FCD6-4729-A0BA-DE5F3DBE875F}" EndProject @@ -12,19 +12,19 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0.repo", "0.repo", "{ACD6EBA3-FC96-43FF-930F-3C148B65F0CE}" ProjectSection(SolutionItems) = preProject .gitignore = .gitignore + .github\copilot-instructions.md = .github\copilot-instructions.md LICENSE = LICENSE README.md = README.md - .github\copilot-instructions.md = .github\copilot-instructions.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1.build", "1.build", "{68A2CD45-02AB-47BE-855C-5C05F363B60C}" ProjectSection(SolutionItems) = preProject Directory.Build.props = Directory.Build.props Directory.Packages.props = Directory.Packages.props - build\Version.props = build\Version.props .github\workflows\dotnet-build.yml = .github\workflows\dotnet-build.yml .github\workflows\nuget-tag-publish.yml = .github\workflows\nuget-tag-publish.yml build\Package.props = build\Package.props + build\Version.props = build\Version.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3.samples", "3.samples", "{F04A0D77-F872-48D9-818B-18C0AAAC466F}" @@ -33,7 +33,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.SampleMcpServe EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2.docs", "2.docs", "{458BB005-58E1-45A0-AFAB-0D7D540319D7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs", "docs\Documents.csproj", "{AD8AA6E1-2503-4D70-935D-1ECC5CF1B745}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Documents", "docs\Documents.csproj", "{AD8AA6E1-2503-4D70-935D-1ECC5CF1B745}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.ModelContextProtocol.Analyzer", "src\DotNetCampus.ModelContextProtocol.Analyzer\DotNetCampus.ModelContextProtocol.Analyzer.csproj", "{550E0E0F-9313-48F2-A29E-F5A3D6AA3869}" EndProject @@ -43,6 +43,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.ModelContextPr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.ModelContextProtocol.TouchSocket.Http", "src\DotNetCampus.ModelContextProtocol.TouchSocket.Http\DotNetCampus.ModelContextProtocol.TouchSocket.Http.csproj", "{D11BF8A7-BF21-4467-8F77-81498CB1111F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.ModelContextProtocol.ClientExtensionsAIConnection.Tests", "tests\DotNetCampus.ModelContextProtocol.ClientExtensionsAIConnection.Tests\DotNetCampus.ModelContextProtocol.ClientExtensionsAIConnection.Tests.csproj", "{84B3F342-D06C-4C76-6C3A-C9655BDC606A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetCampus.ModelContextProtocol.ClientExtensionsAIConnection", "src\DotNetCampus.ModelContextProtocol.ClientExtensionsAIConnection\DotNetCampus.ModelContextProtocol.ClientExtensionsAIConnection.csproj", "{75C14B5C-A440-5056-DEFB-B47E5B4A64D8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -137,6 +141,30 @@ Global {D11BF8A7-BF21-4467-8F77-81498CB1111F}.Release|x64.Build.0 = Release|Any CPU {D11BF8A7-BF21-4467-8F77-81498CB1111F}.Release|x86.ActiveCfg = Release|Any CPU {D11BF8A7-BF21-4467-8F77-81498CB1111F}.Release|x86.Build.0 = Release|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Debug|x64.ActiveCfg = Debug|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Debug|x64.Build.0 = Debug|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Debug|x86.ActiveCfg = Debug|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Debug|x86.Build.0 = Debug|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Release|Any CPU.Build.0 = Release|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Release|x64.ActiveCfg = Release|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Release|x64.Build.0 = Release|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Release|x86.ActiveCfg = Release|Any CPU + {84B3F342-D06C-4C76-6C3A-C9655BDC606A}.Release|x86.Build.0 = Release|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Debug|x64.Build.0 = Debug|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Debug|x86.Build.0 = Debug|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Release|Any CPU.Build.0 = Release|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Release|x64.ActiveCfg = Release|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Release|x64.Build.0 = Release|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Release|x86.ActiveCfg = Release|Any CPU + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -147,5 +175,10 @@ Global {AD8AA6E1-2503-4D70-935D-1ECC5CF1B745} = {458BB005-58E1-45A0-AFAB-0D7D540319D7} {8990631C-4717-4CC2-AB2E-FB417EB0F437} = {EE30C78E-21D1-46DA-AEC2-89654AC30FE3} {D11BF8A7-BF21-4467-8F77-81498CB1111F} = {EE30C78E-21D1-46DA-AEC2-89654AC30FE3} + {84B3F342-D06C-4C76-6C3A-C9655BDC606A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {75C14B5C-A440-5056-DEFB-B47E5B4A64D8} = {EE30C78E-21D1-46DA-AEC2-89654AC30FE3} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C7618025-6132-4A7C-A43E-B148F99509E9} EndGlobalSection EndGlobal 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/coding/mcp-over-cli/design.md b/docs/coding/mcp-over-cli/design.md new file mode 100644 index 0000000..d30d175 --- /dev/null +++ b/docs/coding/mcp-over-cli/design.md @@ -0,0 +1,2 @@ +# MCP over CLI 设计方案 + diff --git a/docs/coding/mcp-over-cli/requirements-raw.md b/docs/coding/mcp-over-cli/requirements-raw.md new file mode 100644 index 0000000..2b4ab96 --- /dev/null +++ b/docs/coding/mcp-over-cli/requirements-raw.md @@ -0,0 +1,132 @@ +# MCP over CLI 需求 + +我有一个设想: + +> 能否给我们的这个 MCP 库增加一个 CLI 传输层? + +当前已经有的传输层有 stdio http ipc in-process,前两个是官方 MCP 协议要求的,后两个是私有协议。现在大家都希望使用 SKILL 技能,因此能否借助现在的 MCP 协议复用应用层协议,增加一个 CLI 传输层协议,使得使用本库的产品能提供 CLI 的调用能力,并可生成配套 SKILL.md 来辅助智能体使用。 + +目前我能想到的一些设计细节如下: + +1. 新增一个 CLI 传输层(仅服务端),因为客户端其实只是在调用 CLI 命令行工具,不需要特别实现新的传输层协议 + - 这是一个真正的传输层吗? + - 也可以做成一个真正的传输层,因为最简的 MCP 通信只需要 `initialize` `tools/call`,相当于我们这个 MCP 服务器连接了一个只实现了最基础协议要求的 MCP 客户端(比如客户端不支持 sampling 等);好处是我们可以完整复用现有的任何 MCP 协议层机制(如日志、拦截层、异常处理流程等,甚至 `IMcpServerCallToolContext` 都可直接使用),坏处是需要在传输层模拟发送 `initialize`(似乎也只需要模拟发送这个即可) + - 也可以做成一个假的传输层?即不用模拟 `initialize`,有工具直接就调用;使用此方案的话,就怕有些特别的状态丢失,导致业务需要特殊考虑是否是被 CLI 调用的(我其实不太期望业务特殊考虑这个 CLI 传输层,要考虑也应该是业务需求,而非解 bug) +2. CLI 传输层传入使用命令行参数吗?传出呢?使用 stdout?当前 Tools.md 里有不同工具返回值的约束和建议,也许可以复用到这里;详细的方案我觉得可以是这样: + - 输入参数: + - 参数映射和命令行语法可参考我们组织的另一个库 DotNetCampus.CommandLine 的设计:[DotNetCampus.CommandLine/README.md](https://github.com/dotnet-campus/DotNetCampus.CommandLine/blob/main/README.md) + - 命令行库支持的输入参数直接传输,不支持的对象参数要求传入 json 格式 + - 无需依赖命令行库,只取其能力,把 MCP over CLI 机制需要的部分在本库重新实现即可 + - 输出内容: + - 方案一:统一输出 JsonRpc 2.0(即 MCP 协议的返回值) + - 好处是完全统一,完全复用 MCP 协议传输层原本的要求 + - 坏处是不再有直观的纯字符串返回值,供人类阅读;相关值也必须转义,而这个转义后的值将直接被大模型读到(极端情况下会让大模型的理解产生困难) + - 方案二:灵活的返回值 + - 比如 string 返回值,直接将字符串输出到 stdout + - 比如非空对象返回值,将其序列化为 json 后输出到 stdout + - 如果错误/异常,则输出一个包含错误信息的 json 对象到 stdout,并以非 0 的退出码退出 + - 方案三:默认使用灵活的返回值,使用 `--output-format text|json` + - `text`(默认),对人类友好,即采用方案二(仅错误时使用 JsonRpc 响应,正常响应直接输出返回值) + - `json`,对程序解析友好,完全符合 MCP 协议的 JsonRpc 2.0 响应格式 + - 也许 stderr 用来做日志记录等,不需要让大模型感知到有效内容 +3. 使用本库的应用可以有两种 CLI 调用 MCP 工具的方式: + - 一次性启动:命令行中输入 `app command --option value` 来启动并执行命令,执行完后即退出 + - 持续运行:命令行中输入 `app command --option value` 执行命令时,本质上只是个空壳转发,所有的命令会转发到一个特定的持续运行的进程中(本方案有一些设计难点:一开始如何启动这个持续运行的进程?如何知道转发给哪个进程?使用什么方式转发命令?) + - 持续运行工作模式,有可能可以复用本库已经有的 IPC 能力;当复用时,可以自然建立确定的传输通道 + - 关于启动,也许 SKILL.md 里可以告诉大模型在使用这些 CLI 工具之前,必须先确保进程启动?例如运行 `app initialize`?这也许也可以跟前面的传输层 `initialize` 关联起来? +4. 生成配套的 SKILL.md(或命令清单,类似于 `app --help` 输出的内容)来辅助智能体使用 CLI 调用 MCP 工具 +5. 大概率 CLI 传输层只是普通传输层的一个小型子集,因为: + - 天生自带「渐进式披露」:不同于常规 MCP 协议传输层,CLI 传输层由 SKILL.md 文件作为披露入口,开发者可生成一个或多个工具命令用法文件来披露不同的命令用法,需要完成哪些场景时再去了解相关的用法,而无需像 MCP 协议一样一开始将所有工具的完整定义放入上下文 + - 天生「功能残缺」:CLI 本身能承载的功能就有限,像 `tools/list` `notifications/*` `sampling` 等难以在 CLI 层面体现 + - 具备工具调用「基本功能」:`tools/call` 和 CLI 的命令调用在功能上非常相似,都是输入命令和参数,输出结果 + +> 关于「持续运行」需求的合理性评估 +> +> 设想一下我们是一个普通 CLI 工具链,本来就在处理各种不同的命令行输入,执行后进行输出;那么我们有必要使用本 MCP over CLI 库吗?其实完全没有必要,因为原本的 CLI 工具链已经足以让智能体完整而充分地使用产品的绝大多数甚至是所有功能了。 +> 但是,为什么有的产品要加入 MCP 协议的支持?很可能是因为这些产品具备一些特征,这些特征使得这样的产品无法改造成一个 CLI 工具,例如: +> - 产品功能复杂,大量功能无法轻易抽象成一个个独立的 CLI 命令,开发者强行去抽也会因为缺乏统一的规范,导致实际的 CLI 命令行难以使用 +> - 产品具有复杂的启动流程,启动完成后会进入一个大量功能同时运作的状态;这样的产品想要改造成一个 CLI 命令执行一个单一步骤的工具,几乎等同于整个产品重写,且重写完后,用户和智能体对产品的用法截然不同,经验无法复用 +> - 然而,MCP 协议的「白盒」接入特点,使得原有的产品可以在完全不影响现有产品功能、启动流程和模块依赖的情况下,硬生生叠一个 MCP 协议层在上面;智能体通过 MCP 工具来使用产品的各项功能,其用法完全是在用户使用之上叠加的,用户和智能体的用法高度一致,经验完全复用;而通过这种方式接入的 MCP 协议,基本无法采用 stdio 传输层(只可能使用 http、ipc 或 in-process 传输层) +> - 可是,如果本库实现了 CLI 传输层,那么这些产品就可以在现有已经实现的 MCP 协议基础上,额外支持 CLI 调用 MCP 工具的能力;这显然就要求本库必须支持单独启动 CLI 进程时,立即将命令行参数原封不动转发到持续运行并带有 MCP 工具的进程中执行 +> - 然而,为什么一定要经过 MCP 协议层呢?直接 CLI 启动 + 命令行参数转发 + IPC 调用不就够了吗?本质上,MCP 协议层对这样的产品是一种能力 + 约束;即本方案没有破坏产品原本提供 MCP http 的能力,http 和 CLI 可同时存在;同时,MCP 协议层也提供了一套统一的工具调用规范(包括基于 MCP 搭建的各种机制,如日志、拦截层、异常处理流程等),这是一套统一开放的约束,比各个产品自己定义一套私有 IPC 转发协议更容易让开发者理解和使用,有现成的资料可查,有广泛使用的软件和产品一起做约束 +> +> 综上所述,MCP over CLI 本质上就是在解决一个持续运行产品高效地接入智能体工作流的问题。这个问题不解决,整个 MCP over CLI 机制将没有任何存在的必要。 +> +> 关于持续运行的程序,我可以列一些参考程序来辅助理解: +> - Blender:作为一款 3D 制作软件,用户可能正在使用,然后中途使用智能体来操作其中的一部分;也可能一开始就使用智能体来操作,由智能体确保负责已启动这款软件;但无论如何,用户始终看着他正在制作 3D 模型的这个界面,能看到智能体对 3D 模型的各项操作(而不是看到一个反复打开的 Blender 程序界面,处理一个步骤后退出,再打开一次处理下一个步骤) +> - PowerPoint:作为一款演示文稿制作软件,具备大量功能;一样的,用户可能正在使用,也可能是通过智能体来确保启动;用户能全程看着智能体对软件的操作,而不是看到一个反复打开、执行一个步骤后关闭、再反复打开的过程 +> 这些程序本来就是为人类使用而设计的,本 MCP over CLI 机制可以额外为其增加为智能体使用而设计的能力;因此,严格来说,智能体并不需要管理程序的启动和关闭,更不需要维护程序的生命周期;只需要在需要时启动它,做完任务,SKILL.md 流程上告诉智能体怎么关闭就关闭,没说也不必在意 + +我能想到的技能文件夹的组织形式大约为: + +- skill-name + - SKILL.md:开发者编写的技能说明文档 + - cli-usages + - command1.md + - command2.md + - command3.md + +这里的 `command1.md`、`command2.md`、`command3.md` 每个文件可能包含多个命令的使用示例。 + +我能想到的初始化方式大约为: + +```csharp +var mcpServer = new McpServerBuilder("SampleMcpServer", "1.0.0") + .WithJsonSerializer(McpToolJsonContext.Default) + .WithTools(t => t + .WithTool(() => new SimpleTool()) + .WithTool(() => new InputTool()) + .WithTool(() => new OutputTool()) + .WithTool(() => new PolymorphicTool()) + .WithTool(() => new ResourceTool()) + .WithTool(() => new SamplingTool()) + ) + .WithSkillCli(new SkillCliOptions + { + // 如果指定,则会将命令用法写到文件(内容没变就不会真的写入);不指定则不写入。 + // API 设计上也许也可以弄成一个委托,由开发者自行决定如何处理用法文档。 + ExportSkillCliUsagesToDirectory = ".agents/skills/sample-mcp-server", + + // 初拟的命令用法分组器,根据工具类型名称和命令名称来决定用哪个文件来写入命令用法;如果不指定,则默认都写到一个文件里。 + CliUsageGroupMapper = (toolTypeName, commandNames) => commandNames.FirstOrDefault() switch + { + "command1" => "command1.md", + "command2" => "command2.md", + "command3" => "command3.md", + _ => "default.md" + }, + + // 初拟的子命令前缀,支持多个单词。如果不指定,是 `app echo`;如果指定了,则是 `app foo echo`。 + // 多个单词之间使用空格分隔,如 `foo bar baz`,那么最终命令就是 `app foo bar baz echo` + CommandPrefix = "foo", + + // 也许还有其他我还没想到的各种属性。 + }) + .Build(); +await mcpServer.RunAsync(); + + +// 也有一种可能,技能说明文档不是通过上述方式生成的,而是下面这种: +var generateSkillCliUsages = CommandLine.Parse(args).As.GenerateSkillCliUsages; +if (generateSkillCliUsages) +{ + // 调用扩展方法,此扩展方法要求必须预先通过 WithSkillCli 注册 CLI 传输层(或者也不用?) + mcpServer.GenerateSkillCliUsagesTo(".agents/skills/sample-mcp-server"); +} +``` + +也许,真正的 MCP 工具上也需要有所标注: + +```csharp +// 名字还没想好,初定 SkillCliCommand 吧 +// 目前想法是允许传多个单词形成多级子命令,如 [SkillCliCommand("advanced echo")],则运行时传入 `app advanced echo` +// 再加上前面初始化的 CommandPrefix,最终命令就是 `app foo advanced echo` +[SkillCliCommand("echo")] +[McpServerTool(ReadOnly = true)] +public string Echo(string text) +{ + return text; +} +``` + +由于我们是一个 MCP 库而非命令行库,所以大概率除特殊场景外,开发者应该不需要在现有的 MCP 工具上进行特殊的 CLI 标注;我也认为,现有 MCP 库已搜集的信息,应该足够让我们生成一整套 CLI 用法文档,并串起整个调用链了。当前的工具名,也许就可以作为隐式子命令的来源(只是隐式推断的话,永远无法指定多级子命令);方法的参数名和类型,目前 MCP 协议已经搜集好了,可以用来生成 CLI 命令的选项(Options,自动加 -- 前缀,将 camelCase 转成 --kebab-case)。 diff --git a/docs/coding/mcp-over-cli/requirements.md b/docs/coding/mcp-over-cli/requirements.md new file mode 100644 index 0000000..4e7bba2 --- /dev/null +++ b/docs/coding/mcp-over-cli/requirements.md @@ -0,0 +1,218 @@ +# MCP over CLI 需求文档 + +## 1. 背景 + +本 MCP 库当前已实现 4 种传输层:stdio、HTTP、IPC、In-Process。其中 stdio 和 HTTP 是 MCP 官方协议规定的传输层,IPC 和 In-Process 是本库的私有协议传输层。 + +当前,业界普遍希望使用 SKILL 技能来增强智能体对工具的调用能力。本需求提出:**能否借助现有的 MCP 协议,复用其应用层协议,增加一个 CLI 传输层,使得使用本库的产品能提供 CLI 调用能力,并可生成配套 SKILL.md 来辅助智能体使用?** + +--- + +## 2. 核心概念 + +MCP over CLI 是一种新的 MCP 传输层方案,它将 MCP 工具的调用能力映射到命令行接口上,使得智能体(以及人类用户)可以通过 CLI 命令来调用 MCP 工具。 + +CLI 传输层仅需要服务端实现——因为客户端本质上只是在调用 CLI 命令行工具,不需要特别实现新的传输层协议。 + +--- + +## 3. CLI 传输层的性质 + +### 3.1 「真传输层」vs「假传输层」 + +CLI 传输层面临一个基本的设计选择: + +- **做成真正的传输层**:模拟一个最小协议要求的 MCP 客户端与服务器通信。最简的 MCP 通信只需要 `initialize` 和 `tools/call`,相当于 MCP 服务器连接了一个只实现了最基础协议要求的 MCP 客户端(例如客户端不支持 sampling 等)。 + - 好处:可以完整复用现有的任何 MCP 协议层机制(如日志、拦截层、异常处理流程等,甚至 `IMcpServerCallToolContext` 都可直接使用)。 + - 坏处:需要在传输层模拟发送 `initialize`(似乎也只需要模拟发送这个即可)。 +- **做成假的传输层**:不模拟 `initialize`,有工具直接就调用。 + - 好处:实现更简单,无需模拟握手流程。 + - 坏处:可能导致某些特别的状态丢失,业务代码需要特殊考虑是否是被 CLI 调用的情形。本需求不期望业务特殊考虑 CLI 传输层——如果要考虑也应该是业务需求驱动,而非为了解 bug。 + +### 3.2 CLI 传输层是普通传输层的小型子集 + +CLI 传输层大概率只是普通传输层的一个小型子集,原因如下: + +- **天生自带「渐进式披露」**:不同于常规 MCP 协议传输层,CLI 传输层由 SKILL.md 文件作为披露入口。开发者可生成一个或多个工具命令用法文件来披露不同的命令用法,需要完成哪些场景时再去了解相关的用法,而无需像 MCP 协议一样一开始将所有工具的完整定义放入上下文。 +- **天生「功能残缺」**:CLI 本身能承载的功能就有限,像 `tools/list`、`notifications/*`、`sampling` 等难以在 CLI 层面体现。 +- **具备工具调用「基本功能」**:`tools/call` 和 CLI 的命令调用在功能上非常相似,都是输入命令和参数、输出结果。 + +--- + +## 4. 输入输出设计 + +### 4.1 输入参数 + +命令行参数的传入方式需要考虑: + +- 参数映射和命令行语法可参考本组织另一个库 DotNetCampus.CommandLine 的设计。 +- 命令行库支持的输入参数直接传输,不支持的对象参数要求传入 JSON 格式。 +- 无需依赖 DotNetCampus.CommandLine 库,只取其能力,把 MCP over CLI 机制需要的部分在本库重新实现即可。 + +当前 MCP 协议已经搜集好了方法的参数名和类型,可用于生成 CLI 命令的选项(Options,自动加 `--` 前缀,将 camelCase 转成 `--kebab-case`)。 + +### 4.2 输出内容 + +输出内容的格式有以下三种候选方案: + +- **方案一:统一输出 JsonRpc 2.0**(即 MCP 协议的返回值) + - 好处:完全统一,完全复用 MCP 协议传输层原本的要求。 + - 坏处:不再有直观的纯字符串返回值供人类阅读;相关值也必须转义,而这个转义后的值将直接被大模型读到(极端情况下会让大模型的理解产生困难)。 +- **方案二:灵活的返回值** + - 例如 string 返回值,直接将字符串输出到 stdout。 + - 例如非空对象返回值,将其序列化为 JSON 后输出到 stdout。 + - 如果错误/异常,则输出一个包含错误信息的 JSON 对象到 stdout,并以非 0 的退出码退出。 +- **方案三:默认使用灵活的返回值,使用 `--output-format text|json` 切换** + - `text`(默认):对人类友好,即采用方案二(仅错误时使用 JsonRpc 响应,正常响应直接输出返回值)。 + - `json`:对程序解析友好,完全符合 MCP 协议的 JsonRpc 2.0 响应格式。 + +另外,也许 stderr 可用来做日志记录等,不需要让大模型感知到有效内容。当前本库的 Tools 文档中有不同工具返回值的约束和建议,也许可以复用到 CLI 输出场景。 + +--- + +## 5. CLI 调用模式 + +使用本库的应用可以有两种 CLI 调用 MCP 工具的方式: + +### 5.1 一次性启动 + +命令行中输入 `app command --option value` 来启动并执行命令,执行完后即退出进程。 + +### 5.2 持续运行 + +命令行中输入 `app command --option value` 执行命令时,本质上只是个空壳转发,所有的命令会转发到一个特定的持续运行的进程中执行。 + +此方案有以下设计难点需要解决: + +- 一开始如何启动这个持续运行的进程? +- 如何知道转发给哪个进程? +- 使用什么方式转发命令? + +关于持续运行工作模式,有可能可以复用本库已经有的 IPC 能力;当复用时,可以自然建立确定的传输通道。 + +关于启动,也许 SKILL.md 里可以告诉大模型在使用这些 CLI 工具之前,必须先确保进程启动?例如运行 `app initialize`?这也许也可以跟前面传输层的 `initialize` 关联起来。 + +--- + +## 6. 「持续运行」需求的合理性评估 + +设想一下我们是一个普通 CLI 工具链,本来就在处理各种不同的命令行输入,执行后进行输出;那么有必要使用本 MCP over CLI 库吗?其实完全没有必要,因为原本的 CLI 工具链已经足以让智能体完整而充分地使用产品的绝大多数甚至是所有功能了。 + +但是,为什么有的产品要加入 MCP 协议的支持?很可能是因为这些产品具备一些特征,这些特征使得这样的产品无法改造成一个 CLI 工具,例如: + +- 产品功能复杂,大量功能无法轻易抽象成一个个独立的 CLI 命令,开发者强行去抽也会因为缺乏统一的规范,导致实际的 CLI 命令行难以使用。 +- 产品具有复杂的启动流程,启动完成后会进入一个大量功能同时运作的状态;这样的产品想要改造成一个 CLI 命令执行一个单一步骤的工具,几乎等同于整个产品重写,且重写完后,用户和智能体对产品的用法截然不同,经验无法复用。 +- 然而,MCP 协议的「白盒」接入特点,使得原有的产品可以在完全不影响现有产品功能、启动流程和模块依赖的情况下,硬生生叠一个 MCP 协议层在上面;智能体通过 MCP 工具来使用产品的各项功能,其用法完全是在用户使用之上叠加的,用户和智能体的用法高度一致,经验完全复用;而通过这种方式接入的 MCP 协议,基本无法采用 stdio 传输层(只可能使用 HTTP、IPC 或 In-Process 传输层)。 +- 可是,如果本库实现了 CLI 传输层,那么这些产品就可以在现有已经实现的 MCP 协议基础上,额外支持 CLI 调用 MCP 工具的能力;这显然就要求本库必须支持单独启动 CLI 进程时,立即将命令行参数原封不动转发到持续运行并带有 MCP 工具的进程中执行。 +- 然而,为什么一定要经过 MCP 协议层呢?直接 CLI 启动 + 命令行参数转发 + IPC 调用不就够了吗?本质上,MCP 协议层对这样的产品是一种能力 + 约束;即本方案没有破坏产品原本提供 MCP HTTP 的能力,HTTP 和 CLI 可同时存在;同时,MCP 协议层也提供了一套统一的工具调用规范(包括基于 MCP 搭建的各种机制,如日志、拦截层、异常处理流程等),这是一套统一开放的约束,比各个产品自己定义一套私有 IPC 转发协议更容易让开发者理解和使用,有现成的资料可查,有广泛使用的软件和产品一起做约束。 + +**综上所述,MCP over CLI 本质上就是在解决一个持续运行产品高效地接入智能体工作流的问题。这个问题不解决,整个 MCP over CLI 机制将没有任何存在的必要。** + +### 6.1 参考程序 + +关于持续运行的程序,以下参考程序可以辅助理解: + +- **Blender**:作为一款 3D 制作软件,用户可能正在使用,然后中途使用智能体来操作其中的一部分;也可能一开始就使用智能体来操作,由智能体确保负责已启动这款软件;但无论如何,用户始终看着他正在制作 3D 模型的这个界面,能看到智能体对 3D 模型的各项操作(而不是看到一个反复打开的 Blender 程序界面,处理一个步骤后退出,再打开一次处理下一个步骤)。 +- **PowerPoint**:作为一款演示文稿制作软件,具备大量功能;一样的,用户可能正在使用,也可能是通过智能体来确保启动;用户能全程看着智能体对软件的操作,而不是看到一个反复打开、执行一个步骤后关闭、再反复打开的过程。 + +这些程序本来就是为人类使用而设计的,本 MCP over CLI 机制可以额外为其增加为智能体使用而设计的能力;因此,严格来说,智能体并不需要管理程序的启动和关闭,更不需要维护程序的生命周期;只需要在需要时启动它,做完任务,SKILL.md 流程上告诉智能体怎么关闭就关闭,没说也不必在意。 + +--- + +## 7. SKILL.md 与命令用法文档 + +### 7.1 技能文件夹的组织形式 + +需要生成配套的 SKILL.md(或命令清单,类似于 `app --help` 输出的内容)来辅助智能体使用 CLI 调用 MCP 工具。 + +技能文件夹的组织形式大约为: + +``` +skill-name/ +├── SKILL.md # 开发者编写的技能说明文档 +└── cli-usages/ + ├── command1.md + ├── command2.md + └── command3.md +``` + +其中 `command1.md`、`command2.md`、`command3.md` 每个文件可能包含多个命令的使用示例。 + +### 7.2 命令用法分组 + +开发者可以将命令用法按需分组到不同的文件中,便于渐进式披露——智能体只需阅读与当前任务相关的命令用法文件,无需一次性加载所有工具的完整定义。 + +--- + +## 8. 初始化 API + +以下为初拟的初始化方式: + +```csharp +var mcpServer = new McpServerBuilder("SampleMcpServer", "1.0.0") + .WithJsonSerializer(McpToolJsonContext.Default) + .WithTools(t => t + .WithTool(() => new SimpleTool()) + .WithTool(() => new InputTool()) + .WithTool(() => new OutputTool()) + .WithTool(() => new PolymorphicTool()) + .WithTool(() => new ResourceTool()) + .WithTool(() => new SamplingTool()) + ) + .WithSkillCli(new SkillCliOptions + { + // 如果指定,则会将命令用法写到文件(内容没变就不会真的写入);不指定则不写入。 + // API 设计上也许也可以弄成一个委托,由开发者自行决定如何处理用法文档。 + ExportSkillCliUsagesToDirectory = ".agents/skills/sample-mcp-server", + + // 初拟的命令用法分组器,根据工具类型名称和命令名称来决定用哪个文件来写入命令用法;如果不指定,则默认都写到一个文件里。 + CliUsageGroupMapper = (toolTypeName, commandNames) => commandNames.FirstOrDefault() switch + { + "command1" => "command1.md", + "command2" => "command2.md", + "command3" => "command3.md", + _ => "default.md" + }, + + // 初拟的子命令前缀,支持多个单词。如果不指定,是 `app echo`;如果指定了,则是 `app foo echo`。 + // 多个单词之间使用空格分隔,如 `foo bar baz`,那么最终命令就是 `app foo bar baz echo` + CommandPrefix = "foo", + + // 也许还有其他我还没想到的各种属性。 + }) + .Build(); +await mcpServer.RunAsync(); +``` + +也有一种可能,技能说明文档不是通过上述方式生成的,而是下面这种: + +```csharp +var generateSkillCliUsages = CommandLine.Parse(args).As.GenerateSkillCliUsages; +if (generateSkillCliUsages) +{ + // 调用扩展方法,此扩展方法要求必须预先通过 WithSkillCli 注册 CLI 传输层(或者也不用?) + mcpServer.GenerateSkillCliUsagesTo(".agents/skills/sample-mcp-server"); +} +``` + +--- + +## 9. MCP 工具上的 CLI 标注 + +也许,真正的 MCP 工具上也需要有所标注: + +```csharp +// 名字还没想好,初定 SkillCliCommand 吧 +// 目前想法是允许传多个单词形成多级子命令,如 [SkillCliCommand("advanced echo")],则运行时传入 `app advanced echo` +// 再加上前面初始化的 CommandPrefix,最终命令就是 `app foo advanced echo` +[SkillCliCommand("echo")] +[McpServerTool(ReadOnly = true)] +public string Echo(string text) +{ + return text; +} +``` + +由于我们是一个 MCP 库而非命令行库,所以大概率除特殊场景外,开发者应该不需要在现有的 MCP 工具上进行特殊的 CLI 标注。现有 MCP 库已搜集的信息,应该足够让我们生成一整套 CLI 用法文档,并串起整个调用链了。 + +当前的工具名,也许就可以作为隐式子命令的来源(只是隐式推断的话,永远无法指定多级子命令);方法的参数名和类型,目前 MCP 协议已经搜集好了,可以用来生成 CLI 命令的选项。 diff --git a/docs/coding/mcp-to-cli-skill/requirements.md b/docs/coding/mcp-to-cli-skill/requirements.md new file mode 100644 index 0000000..c8819f8 --- /dev/null +++ b/docs/coding/mcp-to-cli-skill/requirements.md @@ -0,0 +1,122 @@ +# MCP to CLI Skill 需求 + +## 背景 + +Skill 正在逐步替代 MCP 成为智能体接入世界的主流方式。但是,仍然有一些软件产品非常难改造成 Skill。本需求旨在排除掉各种不同类型软件接入 Skill + CLI 生态的障碍。 + +### 某些软件产品的改造难点 + +设想一种产品,是常见的 CLI 工具链,由于其本身就在处理各种不同的命令行输入,执行后进行输出;其本身就是 CLI 生态的一部分,极容易改造成 Skill + CLI 的方式加入到智能体生态。 + +但设想另一种产品,具备以下特点之一: + +- 产品功能复杂,大量功能无法轻易抽象成一个个独立的 CLI 命令,开发者强行去抽也会因为缺乏统一的规范,导致实际的 CLI 命令行难以使用 +- 产品具有复杂的启动流程,启动完成后会进入一个大量功能同时运作的状态;这样的产品想要改造成一个 CLI 命令执行一个单一步骤的工具,几乎等同于整个产品重写,且重写完后,用户和智能体对产品的用法截然不同,经验无法复用 + +对于这种软件产品,MCP 协议由于其「白盒」接入的特点,使得原有的产品可以在完全不影响现有产品功能、启动流程和模块依赖的情况下,硬生生叠一个 MCP 协议层在上面;智能体通过 MCP 工具来使用产品的各项功能,其用法完全是在用户使用之上叠加的,用户和智能体的用法高度一致,经验完全复用;而通过这种方式接入的 MCP 协议,基本无法采用 stdio 传输层(只可能使用 HTTP、IPC 或 In-Process 传输层)。 + +正因为这类软件几乎无法改造成 CLI 调用方式,所以其难以加入到 Skill + CLi 的智能体生态中,只能通过 MCP 协议(配合 http 传输层)加入。 + +关于这类软件产品,以下参考程序可以辅助理解: + +- **Blender**:作为一款 3D 制作软件,用户可能正在使用,然后中途使用智能体来操作其中的一部分;也可能一开始就使用智能体来操作,由智能体确保负责已启动这款软件;但无论如何,用户始终看着他正在制作 3D 模型的这个界面,能看到智能体对 3D 模型的各项操作(而不是看到一个反复打开的 Blender 程序界面,处理一个步骤后退出,再打开一次处理下一个步骤)。 +- **PowerPoint**:作为一款演示文稿制作软件,具备大量功能;一样的,用户可能正在使用,也可能是通过智能体来确保启动;用户能全程看着智能体对软件的操作,而不是看到一个反复打开、执行一个步骤后关闭、再反复打开的过程。 + +### 现有的解决方案 + +GitHub 上有 MCP to CLI 的现成方案,如: + +- +- + +上述方案的设计思路是非常好的,将 MCP 协议转换为 CLI,方便智能体在无需连接 MCP 工具的情况下通过 CLI 完成工具的调用。但这些方案也有一个缺点: + +> 它们是面向最终用户的方案,具有一定的上手门槛,基本上只有程序员等开发人员群体才可以方便地上手。 + +我们之前也有另一个方案: + +- 本仓库的 `/docs/coding/mcp-over-cli/requirements.md` + +本方案,通过给 MCP 协议引入一个 CLI 传输层,让普通用户拿到 Skill 技能包就能用,无需做任何配置。但本方案也有致命性缺点: + +> 技能包很难找到目标产品的可执行程序路径;或者说可以做到大多数情况下都找得到(甚至可以让某些组织的所有软件产品都找得到),但寻找方法不可穷举,因此也给一部分开发人员造成了上手门槛。 + +而本方案的存在,就是为了解决上述两个方案的痛点的。这是一个全新的方案,与上述方案均无关。 + +## 方案 + +本方案将创造一个智能体技能,面向软件产品的开发者,辅助开发者在开发阶段通过此技能生成适用于软件产品的 Skill + CLI 技能包。 + +### 方案框架如下 + +| | mcp-to-skill-cli | product-skill | +| ---- | ----------------------- | ------------------------------ | +| 定位 | 开发阶段技能 | 用户使用阶段技能 | +| 来源 | 本库提供 | 由 mcp-to-skill-cli 技能生成 | +| 用途 | 创建 product-skill 技能 | 让智能体在用户的指挥下使用产品 | + +### 工作流程 + +在产品开发侧: + +1. 开发者使用 `mcp-to-skill-cli` 技能创建适用于当前产品的技能 +2. 智能体会根据技能要求,沟通以确认如下信息: + - 技能名称 + - 产品主程序路径的查找方法(硬编码路径?在 `PATH` 中直接可用?读注册表?读配置文件?) + - MCP 连接方式(http 传输层?IPC 传输层?)前者难点在于获取端口号,后者要求产品必须实现 IPC 传输层的 MCP 协议 + - 工具元数据生成时机(当下生成?推迟到运行时生成?后者将由智能体发现,并由智能体主动调用工具生成) +3. 智能体根据所有已确认的信息,组装出一个适用于目标产品技能的可执行程序或脚本 + - 此程序或脚本不限制技术栈和框架,但如果开发者不关心具体实现,我们可以内置一套方案 + - 本程序必须具备这些能力 + - 命令行语法解析(如果使用我们内置方案,则采用我们组织自己的 DotNetCampus.CommandLine 库) + - 连接 MCP 服务器的能力(如果使用我们内置方案,则采用我们组织自己的 DotNetCampus.ModelContextProtocol 库,即本库) + - 可在运行时发现目标程序(包括读注册表、配置文件等)、感知其进程存在、得知连接其 MCP 服务器的连接方法(包括获取 IPC 连接名、获取动态端口等)(智能体通过与开发者对话,写下代码以解决这些问题) + - 必须在终端用户环境可直接运行(注意,这很重要,否则目标用户几乎只能是开发人员;如果使用我们内置方案,则可用 .NET AOT 发布,支持多平台) +4. 开发者按需决定工具元数据的版本管理策略 + - 是开发时不生成,运行时生成? + - 是开发时,由智能体生成,并跟随版本管理? + - 是构建时,调用 `mcp-to-skill-cli` 技能中自带的工具,在流水线中生成? +5. 开发者发布产品侧技能 + - 可通过云端服务、仓库等各种途径发布 + - 可在产品安装包内自带 + - 可由用户自行分享 + +在产品用户侧: + +1. 用户从各种途径下载到此产品的智能体技能 + - 可能是产品的安装包中自带 + - 可能是从某些云端服务、仓库等下载 + - 可能是通过其他用户分享渠道下载 +2. 用户直接开始指挥智能体使用软件产品 + +对产品用户侧的智能体而言: + +1. 可通过技能得知如何正确启动软件产品,也知道如何判断当前产品处于正常启动和使用状态 +2. 知道当前软件产品提供了哪些工具,且可从技能额外的工具用法文档中知道各个工具的 CLI 用法 +3. 知道在工具用法文档不存在时,如何通过技能内置的工具生成 CLI 用法文档 +4. 无需关心如何找到目标产品,无需关心如何连接目标产品的 MCP 服务器,甚至无需关心目标产品实际上提供的是 MCP 协议工具 + +### 文件结构 + +产品开发侧 `mcp-to-skill-cli`: + +- SKILL.md:包含上述工作流完整流程,指导开发侧智能体搜集足够多的信息,然后编写代码构建产物,编写和组装用户侧技能 +- templates/:包含内置的工具创建模板 + - dotnet-campus/ + - SKILL.md:包含内置的,使用 dotnet-campus 组织推荐的构建用户侧工具的参考模板,指导智能体完成工具代码的编写 + - skill-management.md:将直接复制给用户端的,用于辅助用户侧智能体了解工具通用用法的参考文档 + - initialize-guide.md:指导开发侧智能体撰写用户侧的 initialize.md 文档,指导用户侧智能体如何启动目标产品软件,如何检查软件状态(本质上都是调用 product-cli 工具) +- tests/:包含一整套测试套件,确保开发侧智能体编写出来的产物能被测试,且用例可完整覆盖 + - README.md:说明哪些内容是测试套件直接覆盖的,哪些内容是需要智能体当场编写测试用例进行验证的,如当场编写应如何确保测试覆盖 + +产品用户侧: + +- SKILL.md:由开发者撰写,辅助智能体在用户侧串起使用工具的全套流程 +- tools/ + - skill-management.md:由 `mcp-to-skill-cli` 技能带去用户端,辅助智能体了解工具的通用用法(例如如何生成工具用法文档等,本质上就是调用 product-cli 工具) + - product-cli.exe:开发时由 AI 编写并构建的 mcp-to-cli 核心工具(若跨平台,则分平台文件夹存放;本技能所有功能都由此工具承接) +- initalize.md:由开发侧智能体撰写,指导用户侧智能体如何启动目标产品软件,如何检查软件状态(本质上都是调用 product-cli 工具) +- references/:可开发侧提前生成并分发,也可在用户侧由智能体生成 + - tool-list.md:含所有工具的简要介绍列表,具体工具用法会引用具体的工具文件 + - Tool1Usage.md:包含所有 MCP 工具的 CLI 用法 + - Tool2Usage.md:工具可分多个文件存放,避免任何一个任务开始导致所有工具被加载 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/JsonSchemaGeneration.md b/docs/en/JsonSchemaGeneration.md new file mode 100644 index 0000000..f9169f6 --- /dev/null +++ b/docs/en/JsonSchemaGeneration.md @@ -0,0 +1,213 @@ +# Compile-Time JSON Schema Generation + +This library provides the `[GenerateJsonSchema]` attribute, which generates JSON Schema at **compile time** — capturing information only available during compilation (such as XML documentation comments) while requiring no runtime reflection. + +MCP [Tools](Tools.md) use the exact same Schema generation logic — the generated JSON Schema is identical. + +## Basic Usage + +### Step 1: Annotate JsonSerializerContext + +Add `[GenerateJsonSchema]` to your `JsonSerializerContext`-derived class: + +```csharp +[GenerateJsonSchema] +[JsonSerializable(typeof(MyModel))] +[JsonSerializable(typeof(MyOtherModel))] +[JsonSourceGenerationOptions( + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +internal partial class MyJsonContext : JsonSerializerContext; +``` + +> Note: JSON Schema property names follow the same rules as MCP tools: `[JsonPropertyName]` wins, otherwise camelCase is used. The generator currently does not read `PropertyNamingPolicy` or `DictionaryKeyPolicy` from `JsonSourceGenerationOptions`. + +### Step 2: Call the Extension Method + +At compile time, each type annotated with `[JsonSerializable]` automatically gets a `GetCompilerGeneratedJsonSchema()` extension method: + +```csharp +// Call directly through JsonTypeInfo +JsonElement schema = MyJsonContext.Default.MyModel.GetCompilerGeneratedJsonSchema(); + +// The result is a standard JSON Schema object +Console.WriteLine(schema.GetProperty("type").GetString()); // "object" +Console.WriteLine(schema.GetProperty("properties")); // Per-property schemas +Console.WriteLine(schema.GetProperty("required")); // Required property list +``` + +## Generated Schema Details + +The following model demonstrates all the capabilities of this feature — required, nullable, enum, nested types, arrays, `[JsonPropertyName]`, etc.: + +```csharp +/// +/// Order information +/// +public record Order +{ + /// + /// Order ID + /// + public required string Id { get; init; } + + /// + /// Order amount (optional) + /// + public decimal? Amount { get; init; } + + /// + /// Order status + /// + public OrderStatus Status { get; init; } + + /// + /// Order items + /// + public required List Items { get; init; } +} + +/// +/// Order status +/// +public enum OrderStatus +{ + /// Pending + Pending, + /// Shipped + Shipped, + /// Done + Done, +} + +/// +/// Order item +/// +public record OrderItem +{ + /// + /// Product name + /// + [JsonPropertyName("product_name")] + public required string ProductName { get; init; } + + /// + /// Quantity + /// + public int Quantity { get; init; } +} +``` + +```csharp +var schema = MyJsonContext.Default.Order.GetCompilerGeneratedJsonSchema(); +``` + +Generated JSON Schema: + +```json +{ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Order ID" }, + "amount": { "type": ["number", "null"], "description": "Order amount (optional)" }, + "status": { + "type": "string", + "description": "Order status\nPending: Pending\nShipped: Shipped\nDone: Done", + "enum": ["Pending", "Shipped", "Done"] + }, + "items": { + "type": "array", + "description": "Order items", + "items": { + "type": "object", + "properties": { + "product_name": { "type": "string", "description": "Product name" }, + "quantity": { "type": "integer", "description": "Quantity" } + }, + "required": ["product_name"] + } + } + }, + "required": ["id", "items"] +} +``` + +Property-by-property breakdown: + +| Property | Mechanism | +| -------------- | ------------------------------------------------------------------------------- | +| `id` | `required` modifier → added to `required` list; XML comment → `description` | +| `amount` | `decimal?` nullable value type → `type` becomes `["number", "null"]` | +| `status` | Enum type → `enum` array generated; enum member `` is appended to `description` | +| `items` | Nested type → `items` recursively generates `OrderItem`'s full Schema | +| `product_name` | `[JsonPropertyName("product_name")]` → Schema property name uses `product_name` | +| `quantity` | No `required` modifier → not added to the `required` list | + +> **Recursive nature**: XML comment extraction is recursive: nested property `` comments are extracted at any depth. The object type's own `` is currently not written to that object Schema's `description`. + +## Typical Scenario: On-Demand Schema to Avoid Context Pollution + +When you need to support dozens of different object types, directly using [polymorphic types](Polymorphism.md) would cause the MCP tool's Schema to include all subtype definitions, significantly bloating it and polluting the agent's context window. + +With this feature, you can set the parameter type to `JsonElement` and provide specific schemas on demand through an additional tool. + +### The Problem: Schema Bloat from Polymorphism + +For example, the following MCP tool uses `EventBase`, which has dozens of subtypes. The MCP tool collects all subtypes into a single JSON Schema, causing severe bloating. + +```csharp +[McpServerTool] +public string HandleEvent(EventBase evt) { ... } +``` + +### The Solution: JsonElement + On-Demand Schema Tool + +Step 1: Change the parameter type to `JsonElement`, bypassing subtype schema generation: + +```csharp +// Only expose a generic JsonElement, avoiding polymorphism bloat +[McpServerTool] +public string HandleEvent(JsonElement evt) { ... } +``` + +Step 2: Use `[GenerateJsonSchema]` to generate schemas for each concrete event type: + +```csharp +[GenerateJsonSchema] +[JsonSerializable(typeof(ClickEvent))] +[JsonSerializable(typeof(KeyPressEvent))] +[JsonSerializable(typeof(ScrollEvent))] +// ... more event types +internal partial class EventSchemaContext : JsonSerializerContext; +``` + +Step 3: Add an MCP tool that lets the agent query individual type schemas on demand: + +```csharp +/// +/// Gets the JSON Schema for a specific event type. +/// Call this tool to learn the field structure before constructing an event. +/// +/// Event type name, e.g. "ClickEvent", "KeyPressEvent" +[McpServerTool(ReadOnly = true)] +public JsonElement GetEventSchema(string eventType) +{ + return eventType switch + { + nameof(ClickEvent) => EventSchemaContext.Default.ClickEvent + .GetCompilerGeneratedJsonSchema(), + nameof(KeyPressEvent) => EventSchemaContext.Default.KeyPressEvent + .GetCompilerGeneratedJsonSchema(), + nameof(ScrollEvent) => EventSchemaContext.Default.ScrollEvent + .GetCompilerGeneratedJsonSchema(), + _ => throw new ArgumentException($"Unknown event type: {eventType}"), + }; +} +``` + +### Result + +- `HandleEvent`'s Schema stays clean and minimal, exposing only a `JsonElement` parameter +- The agent calls `GetEventSchema` on demand when it needs to understand a specific event's structure +- Both tools share the **exact same schema generation logic**, so `GetEventSchema` returns schemas that precisely match what `HandleEvent` expects 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/Polymorphism.md b/docs/en/Polymorphism.md new file mode 100644 index 0000000..51d3903 --- /dev/null +++ b/docs/en/Polymorphism.md @@ -0,0 +1,82 @@ +# Polymorphic Types + +When a tool parameter or return value needs to support multiple subtypes, such as different shapes of messages, events, or commands, you can use C# polymorphic types. This library supports type polymorphism through System.Text.Json's `[JsonPolymorphic]` / `[JsonDerivedType]` attributes. + +Polymorphism applies to both tool input parameters and return values. + +## Defining Polymorphic Types + +An example using type polymorphism: + +```csharp +[JsonDerivedType(typeof(PolymorphicDerivedA), "a")] +[JsonDerivedType(typeof(PolymorphicDerivedB), "b")] +[JsonDerivedType(typeof(PolymorphicDerivedC), "c")] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +public abstract record PolymorphicBase +{ +} + +public record PolymorphicDerivedA : PolymorphicBase +{ + public string? Foo { get; init; } +} + +public record PolymorphicDerivedB : PolymorphicBase +{ + public int? Bar { get; init; } +} + +public record PolymorphicDerivedC : PolymorphicBase +{ + public JsonElement? Baz { get; init; } +} +``` + +In this example: + +- `[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]` specifies the discriminator JSON property name. +- `[JsonDerivedType(typeof(...), "...")]` declares each subtype and its discriminator value. +- Ensure `JsonSerializerContext` can provide serialization metadata for the polymorphic type; when it is used directly as a tool parameter or return value, register the polymorphic base type. + +## Input Polymorphism + +A tool method parameter can be declared directly as the polymorphic base type, for example: + +```csharp +/// +/// Tests a polymorphic parameter. +/// +/// The polymorphic parameter. +[McpServerTool(ReadOnly = true)] +public string TestPolymorphicParameter(PolymorphicBase param) +{ + return param switch + { + PolymorphicDerivedA a => $"Received DerivedA with Foo = {a.Foo}", + PolymorphicDerivedB b => $"Received DerivedB with Bar = {b.Bar}", + _ => "Unknown type", + }; +} +``` + +> **⚠ Important**: If the JSON sent by the client omits the discriminator property, or the discriminator value does not match any subtype declared by `[JsonDerivedType]`, this library throws `McpToolMissingRequiredTypeDiscriminatorException` and reports an error to the client. The error message includes the expected discriminator property name and the list of valid values. + +Polymorphism is also supported on any property of input parameters, including recursively nested descendant properties. + +## Output Polymorphism + +A method return value can also be a polymorphic type. + +```csharp +/// +/// Returns a polymorphic object. +/// +[McpServerTool(ReadOnly = true)] +public PolymorphicBase GetPolymorphicResult() +{ + return new PolymorphicDerivedA { Foo = "result" }; +} +``` + +For non-nullable custom object return values, the library generates polymorphic `outputSchema` and `structuredContent` by default. 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..be4850a 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](Tools.md). -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..81ef719 --- /dev/null +++ b/docs/en/README.md @@ -0,0 +1,23 @@ +# 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) +- MCP Transport + - [Choosing a Transport](Transport.md) +- Library Mechanisms + - [Dependency Injection](DependencyInjection.md) + - [Polymorphic Types](Polymorphism.md) + - [Compile-Time JSON Schema Generation](JsonSchemaGeneration.md) diff --git a/docs/en/Resources.md b/docs/en/Resources.md new file mode 100644 index 0000000..2c29b32 --- /dev/null +++ b/docs/en/Resources.md @@ -0,0 +1,167 @@ +# 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`. + +Similar to tool methods, resource methods can also access transport session information (such as SessionId, client name/version, etc.) via `context.TransportSession`, and HTTP transport-specific information (such as request headers) via `context.HttpTransportContext`. See [context details in the Tools documentation](Tools.md#imcpservercalltoolcontext) for more. + +## 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..4e7759b --- /dev/null +++ b/docs/en/Tools.md @@ -0,0 +1,465 @@ +# 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. +- **Structured**: Controls whether structured output (outputSchema + structuredContent) is generated for this tool: + - **Not set**: Automatically generates structured output for non-nullable object types; produces compilation error DM0102 for nullable object or object collection types, requiring explicit `false` + - **true**: Explicitly enables structured output. Only valid for non-nullable object types; produces compilation error DM0101 for non-structurable types, and DM0103 for nullable objects and object collections + - **false**: Explicitly disables structured output. Valid for all types; no outputSchema generated + +> Both input and output JSON Schemas are generated at compile time, capturing information only available during compilation without runtime reflection. For generation rules and limitations, see [Compile-Time JSON Schema Generation](JsonSchemaGeneration.md). + +For example, tools returning custom object types generate structured output by default; if not needed, you can explicitly disable it: + +```csharp +[McpServerTool(ReadOnly = true, Structured = false)] +public LocalTimeInfo GetTime() { ... } +``` + +### Parameters and Context + +#### Parameter Types + +Tool methods support a variety of parameter types. The table below summarizes the annotation method, whether the parameter appears in the input Schema, and the source of its runtime value. + +> In the table, "—" means the parameter does not appear in the input Schema. `[ToolParameter]` also supports `Name` (overrides the JSON property name) and `Description` (overrides the parameter description), which are common to all types and not listed separately. + +| Parameter Type | Annotation | Input Schema | Runtime Value | +|---|---|---|---| +| `IMcpServerCallToolContext` | Automatic | — | Forwards the current `context` | +| JSON-deserializable types (primitives, string, objects, etc.) | Automatic | Yes | `jsonArguments["name"]` deserialized to .NET type | +| `JsonElement` / `object` | Automatic | Yes (arbitrary JSON) | `jsonArguments["name"]` passed as-is | +| Entire input object `[ToolParameter(Type = InputObject)]` | Must annotate | Yes (expanded as properties) | Entire `jsonArguments` deserialized | +| DI injection `[ToolParameter(Type = Injected)]` (nullable) | Must annotate | — | `GetService()`; unregistered → `null` | +| DI injection `[ToolParameter(Type = Injected)]` (non-nullable) | Must annotate | — | `GetService()`; unregistered → `McpToolServiceNotFoundException` | +| `CancellationToken` | Automatic | — | `context.CancellationToken` | + +Automatic annotation means the source generator infers the behavior from the parameter type. Once any parameter is annotated with `InputObject`, **no further** plain JSON parameters are allowed. Parameters annotated with `Injected` receive their values from `IServiceProvider`, which must be configured via `WithServices()` during server initialization. See [Dependency Injection](DependencyInjection.md) for details. + +> **💡 Tip**: Place `CancellationToken` and `IMcpServerCallToolContext` at the end of the parameter list to keep JSON parameters readable. + +> **💡 Tip**: JSON-serializable types can support polymorphism through type discriminators, including properties at any nesting level. See [Polymorphic Types](Polymorphism.md). + +#### 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`) +- Transport session (`context.TransportSession`), available across all transport layers, including: + - `SessionId`: Session ID for distinguishing different client connections (`null` under stdio transport) + - `ConnectedClientInfo`: Client information provided during initialization handshake (name, version, etc.) + - `ConnectedClientCapabilities`: Capabilities declared by the client + - `NegotiatedProtocolVersion`: Negotiated protocol version +- HTTP transport context (`context.HttpTransportContext`), only available under HTTP transport, including: + - `SessionId`: Same as `TransportSession.SessionId` + - `Headers`: HTTP request headers of the current request + +> **💡 Tip**: To distinguish different clients, prefer `context.TransportSession` — it works across all transport layers (HTTP, stdio, InProcess, IPC). `context.HttpTransportContext` is only available under HTTP transport and is suited for scenarios that require reading HTTP request headers. + +> **⚠ 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`, required parameters (without default values), and optional parameters (with default values): + +```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 session = context.TransportSession; + var info = $""" + Server name: {context.McpServer.ServerName} + SessionId: {session?.SessionId} + Client: {session?.ConnectedClientInfo?.Name} {session?.ConnectedClientInfo?.Version} + Protocol: {session?.NegotiatedProtocolVersion} + 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. This example returns `Task`; since `EchoResult` is a non-nullable object, structured output is enabled by default. See the [Return Value Types](#return-value-types) table below for the complete rules.) + +### Return Value Types + +The method's return type determines how the compile-time source generator processes the return value and the `CallToolResult` structure generated at runtime. You can further control whether MCP structured output is generated via the `Structured` property (see above). + +The table below summarizes all supported return types and their behavior. Recommendation levels: + +- **Recommended**: The return value behavior fully complies with MCP protocol requirements — no conversion needed +- **Supported**: This library processes the return value to make it MCP-compliant +- **Not recommended**: May produce results non-compliant with MCP in some scenarios, potentially causing errors in some MCP clients +- **Self-managed**: This library does not intervene; the developer fully controls the protocol-level data + +> In the table, "—" means Structured is not applicable (setting `true` → DM0101). "false only" means the setting must be explicit: unset → DM0102, `true` → DM0103. + +| Return Type | Level | Structured | Output Schema | Runtime Behavior | +|---|---|---|---|---| +| `string` | Recommended | — | — | TextContentBlock(text) | +| Non-nullable custom object (record/class) `Foo` | Recommended | Default true; can set false to disable | Default: OutputSchema generated | Default: StructuredContent + TextContentBlock(json); Structured=false: TextContentBlock(json) only | +| `string?` | Supported | — | — | TextContentBlock(text or "") | +| Nullable primitives / nullable enums (`int?`/`bool?`/`DayOfWeek?` etc.) | Supported | — | — | ToString(); null→"" | +| Primitives / enums (`int`/`bool`/`DayOfWeek` etc.) | Supported | — | — | ToString() | +| `JsonElement` | Supported | — | — | TextContentBlock(json) | +| `void` / `Task` / `ValueTask` | Not recommended | — | — | [] | +| Nullable custom object (record/class) `Foo?` | Not recommended | false only | — | json→TextContentBlock; null→"" | +| Primitive collections (`string[]`/`int[]`/`IReadOnlyList` etc.) | Not recommended | — | — | Per-element ToString()→multiple blocks; null/empty→[] | +| Object collections `Foo[]` / `IReadOnlyList` | Not recommended | false only | — | Per-element json→multiple blocks; null/empty→[] | +| `CallToolResult` | Self-managed | — | — | Returned as-is | + +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, where `T` is any of the types in the table above with identical behavior + +For non-nullable custom objects, polymorphism can be supported through type discriminators, including properties at any nesting level. See [Polymorphic Types](Polymorphism.md). + +### 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; // string implicitly converts to CallToolResult +} +``` + +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/input-output-issues.md b/docs/input-output-issues.md new file mode 100644 index 0000000..13d82c0 --- /dev/null +++ b/docs/input-output-issues.md @@ -0,0 +1,880 @@ +# MCP 工具输入输出 Schema 与 JSON 序列化一致性问题 + +## 1. 文档目的 + +本文是一次开发任务交接文档,记录 MCP 工具输入参数、输出值、JSON Schema、源生成代码和 `System.Text.Json` 序列化契约之间已经发现的问题。 + +下一次新会话开始实现前,应先阅读本文,再检查文中引用的源码。不要只修复枚举默认值 `"1"`;该问题只是同一类架构不一致中最明显的一个表现。 + +本文区分三类结论: + +- **已确认缺陷**:已经通过源码或生成代码确认。 +- **已确认设计不一致**:当前行为不一定立刻报错,但 Schema 与实际 wire format 可能分离。 +- **待决策项**:修复前需要确定兼容性和公开契约。 + +本文不宣称已经完成整个 `System.Text.Json` 契约的穷尽审计。最后列出了应继续检查的相邻风险。 + +## 2. 问题背景 + +本库使用源生成器分析带有 `[McpServerTool]` 的方法,并在编译期生成: + +- 工具名称和描述。 +- `inputSchema`。 +- 可选的 `outputSchema`。 +- 输入参数提取和反序列化代码。 +- 返回值到 `CallToolResult` 的转换代码。 + +当前 Schema 和运行时 JSON 行为来自两套不同的信息源: + +1. **编译期 Schema** + - 由 Analyzer 直接分析 Roslyn 符号。 + - 属性名默认由本库转换成 camelCase。 + - 枚举 Schema 固定使用字符串类型和 CLR 枚举成员名。 + - Schema 生成时拿不到服务器实际配置的业务 `JsonSerializerContext`。 + +2. **运行时序列化和反序列化** + - 使用 `McpServerBuilder.WithJsonSerializer(...)` 传入的业务 `JsonSerializerContext`。 + - 受 `JsonSourceGenerationOptions`、`JsonPropertyName`、`JsonStringEnumMemberName`、类型转换器等影响。 + +根本风险是: + +```text +编译期声明给客户端的 JSON Schema +可能不等于 +运行时真正接受或产生的 JSON +``` + +当前 `IMcpServerTool.GetToolDefinition` 只接收 `CompiledSchemaJsonContext`,工具列表请求也固定用 `CompiledSchemaJsonContext.Default`。业务 `JsonSerializerContext` 没有参与 Schema 构造: + +- `src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerTool.cs` +- `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs:213` +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:19-30` + +## 3. 当前公开参数分类 + +`ToolParameterType` 有 6 种值: + +1. `Parameter` +2. `InputObject` +3. `Context` +4. `Injected` +5. `JsonElement` +6. `CancellationToken` + +源码: + +- `src/DotNetCampus.ModelContextProtocol/CompilerServices/ToolParameterAttribute.cs` +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/ToolSymbolExtensions.cs:41-75` + +文档应使用这 6 种值作为参数“来源/用途”的主分类。以下内容不应被描述成新的 `ToolParameterType`: + +- 可空和非可空 DI:都是 `Injected`,只是服务缺失时行为不同。 +- 枚举、字符串、数字、对象、集合:都是 `Parameter` 或 `InputObject` 内部的 JSON 数据形态。 +- 必需参数和带默认值参数:是 required/default 行为,不是新的参数类型。 + +建议文档采用两层结构: + +1. 第一层按 6 种 `ToolParameterType` 说明参数来源和是否进入 Schema。 +2. 第二层在 `Parameter`/`InputObject` 下说明 string、boolean、number、enum、object、array、dictionary、nullable 等 JSON Schema 行为。 + +## 4. 问题清单 + +### 4.1 已确认缺陷:枚举默认值生成成底层数字字符串 + +示例: + +```csharp +public enum EchoOptions +{ + PlainText, + JsonObject, +} + +public CallToolResult Echo( + string text, + EchoOptions options = EchoOptions.JsonObject) +``` + +当前生成结果: + +```csharp +[ "options" ] = new CompiledJsonSchema +{ + Type = JsonSerializer.SerializeToElement("string", jsonContext.String), + Default = JsonSerializer.SerializeToElement("1", jsonContext.String), + Enum = [ "PlainText", "JsonObject" ], +}, +``` + +Schema 自相矛盾: + +```text +default = "1" +enum = ["PlainText", "JsonObject"] +``` + +`"1"` 不属于允许值。 + +直接原因: + +- 枚举在 Schema 中映射为 `string`: + `src/DotNetCampus.ModelContextProtocol.Analyzer/CodeAnalysis/JsonSchemaType.cs:116` +- `IParameterSymbol.ExplicitDefaultValue` 对枚举给出底层常量。 +- 默认值生成逻辑按照 Schema 的 `String` 分支执行 `defaultValue.ToString()`: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/JsonPropertySchemaInfo.cs:321-339` + +修复要求: + +- 枚举必须单独处理,不得复用普通 string 默认值分支。 +- 生成的非空默认值必须满足 `default` 属于 `enum`。 +- 测试必须覆盖底层值为 0、非 0 和自定义数值的成员。 + +### 4.2 已确认缺陷:枚举 Schema 不遵循 `JsonStringEnumMemberName` + +当前 `EnumJsonValueInfo` 直接使用 `IFieldSymbol.Name`: + +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/EnumJsonValueInfo.cs:31-45` +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/JsonPropertySchemaInfo.cs:135-145` + +例如: + +```csharp +public enum EchoOptions +{ + [JsonStringEnumMemberName("jsonObject")] + JsonObject, +} +``` + +运行时字符串枚举转换器可以接受或写出 `"jsonObject"`,但当前 Schema 仍声明 `"JsonObject"`。 + +该问题同时影响: + +- 直接枚举参数。 +- 可空枚举参数。 +- 枚举集合的 `items.enum`。 +- 输入对象中的枚举属性。 +- 结构化输出对象中的枚举属性。 +- 枚举默认值。 +- 自动附加到描述中的枚举值提示。 + +### 4.3 已确认设计不一致:枚举 Schema 固定为字符串,但运行时未强制使用字符串枚举 + +当前生成器固定输出: + +```json +{ + "type": "string", + "enum": ["PlainText", "JsonObject"] +} +``` + +运行时反序列化却使用业务 `JsonSerializerContext`: + +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:202-205` +- `src/DotNetCampus.ModelContextProtocol/CompilerServices/CompilerExtensions.cs:73-81` + +如果业务上下文没有有效的字符串枚举转换器,Schema 要求客户端发送字符串,但运行时可能按数字枚举处理。 + +当前文档仅把 `UseStringEnumConverter = true` 写成“建议设置”,不足以表达契约要求: + +- `docs/zh-hans/QuickStart.md:39-49` + +注意:要求不应机械表述成“必须设置 `UseStringEnumConverter = true`”,因为枚举类型自身的 `[JsonConverter(typeof(JsonStringEnumConverter))]` 也可以提供字符串契约。准确要求应是: + +> 对所有出现在工具 JSON payload 中的枚举,实际 `JsonTypeInfo` 必须使用与 Schema 一致的字符串 wire format。 + +### 4.4 已确认缺陷:直接枚举返回值绕过 JSON 枚举契约 + +普通枚举返回值当前使用 `ToString()`: + +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:360-391` + +枚举集合也逐项使用字符串插值,本质仍是 `ToString()`: + +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:286-290` + +因此: + +```csharp +[JsonStringEnumMemberName("jsonObject")] +JsonObject +``` + +可能出现: + +```text +输入 Schema / 对象 JSON:jsonObject +直接枚举返回文本:JsonObject +``` + +需要决定直接枚举返回究竟是: + +1. 普通文本展示值,明确固定使用 CLR `ToString()`;或 +2. JSON wire value,必须通过实际枚举 `JsonTypeInfo` 序列化。 + +推荐采用第 2 种,确保输入、对象输出、直接输出和集合输出使用同一 wire value。若序列化后得到 JSON 字符串,需要提取字符串内容,而不是把带引号的 JSON 文本直接放进 `TextContentBlock`。 + +### 4.5 已确认设计不一致:对象属性 Schema 固定 camelCase,不遵循业务命名策略 + +顶级方法参数名和对象属性名目前由生成器自行决定: + +- 方法参数默认 camelCase,可由 `[ToolParameter(Name = "...")]` 覆盖: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/ToolSymbolExtensions.cs:92-110` +- 对象属性默认 camelCase,可由 `[JsonPropertyName]` 覆盖: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/ToolSymbolExtensions.cs:139-153` + +顶级普通方法参数不存在对象属性序列化过程。生成器用同一个名称生成 Schema 并从 `jsonArguments` 取值,因此这一层内部是一致的。 + +真正的不一致发生在对象内部: + +- 普通对象参数。 +- `[ToolParameter(Type = InputObject)]` 的整个输入对象。 +- 结构化输出对象。 +- 对象集合元素。 +- 嵌套对象。 + +例如业务上下文配置: + +```csharp +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +``` + +可能得到: + +```text +Schema 属性名:someValue +运行时 JSON 属性名:some_value +``` + +`[JsonPropertyName]` 当前在 Schema 和运行时两边都能生效,是已支持的显式覆盖机制。 + +### 4.6 已确认设计不一致:Schema 不会自动遵循 `JsonSourceGenerationOptions` + +业务上下文的这些设置可能影响实际 JSON: + +- `PropertyNamingPolicy` +- `UseStringEnumConverter` +- 未来或其他可影响 wire shape 的序列化选项 + +但 Analyzer 生成 Schema 时不知道服务器最终会传入哪个 `JsonSerializerContext`。同一个工具类型理论上还可以注册到使用不同上下文的不同服务器。 + +当前架构中: + +- Schema 是编译期硬编码到桥接类的。 +- `GetToolDefinition` 只接收 `CompiledSchemaJsonContext`。 +- `McpServerRequestHandlers` 获取工具列表时没有把业务上下文传给工具。 + +所以“让现有编译期 Schema 自动完整遵循任意业务 `JsonSourceGenerationOptions`”不是局部改动,必须先选择架构方案,见第 7 节。 + +### 4.7 已确认缺陷:多态类型在未指定 discriminator 时自行发明类型名 + +`PolymorphicTypeInfo` 在 `[JsonDerivedType(typeof(Foo))]` 没有显式 discriminator 时使用 `derivedType.Name`: + +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/PolymorphicTypeInfo.cs:67-82` + +随后 Schema 强制添加鉴别器属性和 `const`: + +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:87-93` +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:113-143` + +这不等同于 `System.Text.Json` 的实际契约。没有显式 type discriminator 的 `[JsonDerivedType]` 不应由本库擅自创造一个 wire discriminator。 + +修复方向: + +- 仅在 `JsonDerivedType` 显式提供 discriminator 时生成 discriminator Schema;或 +- 对工具参数要求所有派生类型显式声明 discriminator,并给出编译诊断。 + +推荐第二种,因为工具输入需要可反序列化、可向模型明确描述的稳定联合类型。 + +### 4.8 已确认缺陷:整数多态 discriminator 被错误转换为字符串 + +`JsonDerivedTypeAttribute` 支持字符串或整数 discriminator。当前代码把整数执行 `ToString()`: + +- `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/PolymorphicTypeInfo.cs:67-76` + +`CompiledJsonSchema.Const` 又被定义成 `string?`: + +- `src/DotNetCampus.ModelContextProtocol/CompilerServices/CompiledJsonSchema.cs` + +因此整数 discriminator `1` 会被声明成字符串常量 `"1"`,而运行时 JSON 契约要求数字 `1`。 + +修复要求: + +- discriminator value 必须保留 JSON 类型。 +- `const` 不能只支持 string,建议改为 `JsonElement?` 或可表达标量的结构。 +- 测试字符串和整数 discriminator。 + +### 4.9 已确认文档问题:参数类型表混合了“参数来源”和“.NET/JSON 类型” + +`docs/zh-hans/Tools.md:84-94` 当前表格把以下概念放在同一层: + +- `IMcpServerCallToolContext` +- JSON 可序列化类型 +- `JsonElement` +- `InputObject` +- 可空 DI +- 非空 DI +- `CancellationToken` + +这与公开的 6 个 `ToolParameterType` 不一致,也造成: + +- `Injected` 被按可空性拆成两种“参数类型”。 +- 枚举被隐藏在“JSON 可序列化类型”中。 +- 用户难以理解枚举是 `Parameter` 的一种 Schema 形态,而不是类似 `Injected` 的参数来源。 + +修复后的文档结构见第 3 节。 + +### 4.10 已确认文档问题:返回值表把枚举与数值和布尔值混为一类 + +当前: + +- `docs/zh-hans/Tools.md:178-179` +- `docs/zh-hans/Tools.md:183` + +表格把枚举与 `int`、`bool` 共同描述为基本类型,虽然运行代码都是 `ToString()`,但 wire 语义不同: + +- `int` 返回数字的文本表示。 +- `bool` 返回布尔值的文本表示。 +- 枚举返回成员名文本,目前更接近字符串枚举。 + +文档应单列: + +- 枚举。 +- 可空枚举。 +- 枚举集合。 + +并根据最终实现说明是否遵循 `JsonStringEnumMemberName`。 + +### 4.11 已确认文档问题:只说“自定义类型”需要 JSON source generation 注册 + +当前: + +- `docs/zh-hans/QuickStart.md:18` +- `docs/zh-hans/Tools.md:290` + +实际直接枚举参数也需要业务上下文提供相应 `JsonTypeInfo`。示例工程已经显式注册: + +- `samples/DotNetCampus.SampleMcpServer/McpTools/McpToolJsonContext.cs:6` + +文档应明确: + +- 直接使用的业务枚举需要注册。 +- 业务对象、集合及其嵌套类型需要确保上下文能返回对应的 `JsonTypeInfo`。 +- 具体应注册根类型还是同时注册元素类型,应以 source-generated context 实测和测试为准,文档不要给出未经测试的过度承诺。 + +### 4.12 已确认文档问题:`UseStringEnumConverter` 的说明过弱且不完整 + +当前注释为“建议设置:MCP 协议主流实现都使用字符串枚举”。 + +更准确的说明应是: + +- 本库当前为工具枚举生成字符串 Schema。 +- 因此运行时枚举 JSON 契约必须与该 Schema 一致。 +- `UseStringEnumConverter = true` 是实现这一契约的常见方式。 +- 枚举类型上的 `JsonConverter` 也可能实现该契约。 +- 任意自定义转换器是否受支持,要看本库最终选择的架构和诊断策略。 + +## 5. 枚举特殊边界 + +以下边界尚未完成实现决策,但修复时必须覆盖。 + +### 5.1 默认命名风格 + +建议保持当前兼容行为: + +```text +默认 wire value = 原始 C# 枚举成员名 +``` + +例如: + +```text +JsonObject -> "JsonObject" +``` + +不要默认改成 camelCase,原因: + +- `UseStringEnumConverter = true` 默认保持成员名。 +- `PropertyNamingPolicy` 不控制枚举值。 +- 当前直接枚举返回使用 `ToString()`。 +- 改成 camelCase 是公开 wire format 的破坏性变更。 + +显式命名建议遵循: + +```text +[JsonStringEnumMemberName] > CLR 成员名 +``` + +### 5.2 枚举别名 + +例如: + +```csharp +enum State +{ + Ready = 1, + Started = 1, +} +``` + +`IParameterSymbol.ExplicitDefaultValue` 只能看到底层值 `1`,无法仅凭数值确定默认值源码写的是 `Ready` 还是 `Started`。 + +可选方案: + +1. 从参数默认值语法节点读取实际成员表达式。 +2. 规定别名默认值选择第一个声明成员,但这可能改变用户意图。 +3. 遇到别名默认值产生诊断,要求用户移除默认值或消除歧义。 + +推荐优先尝试方案 1,无法可靠解析时使用诊断。 + +### 5.3 未命名枚举值 + +例如: + +```csharp +EchoOptions options = (EchoOptions)42 +``` + +该默认值无法满足有限字符串枚举 Schema。不得继续生成 `"42"`。 + +推荐产生编译诊断,说明默认值没有对应的已声明字符串成员。 + +### 5.4 `[Flags]` + +字符串枚举转换器通常允许逗号分隔的组合值,但当前 Schema 的有限 `enum` 列表只包含单个成员,不能表达任意组合。 + +需选择: + +1. `[Flags]` 使用 `type: "string"`,不生成 `enum` 限制,只在描述中列出标志。 +2. 生成所有组合值。通常不可取,组合数量指数增长。 +3. 暂不支持 `[Flags]` 工具参数并产生诊断。 + +推荐方案 1,并明确组合字符串格式必须与实际转换器一致。 + +### 5.5 任意自定义枚举转换器 + +Analyzer 无法可靠推断任意用户 `JsonConverter` 的 wire value。不要宣称支持所有自定义转换器。 + +第一阶段建议明确支持: + +- 默认 CLR 成员名。 +- `JsonStringEnumMemberName`。 +- 标准字符串枚举转换器。 + +对枚举类型上的未知自定义转换器可: + +- 产生诊断或警告;或 +- 要求用户通过本库新增的显式 Schema/wire-name 配置覆盖。 + +## 6. 当前隐式命名转换清单 + +| 位置 | 当前默认规则 | 显式覆盖 | 是否读取业务 JSON 上下文 | +|---|---|---|---| +| 工具名 | 方法名转 snake_case | `McpServerTool.Name` | 否,也不应读取 | +| 顶级工具参数名 | 参数名转 camelCase | `ToolParameter.Name` | 否 | +| 输入/输出对象属性 | 属性名转 camelCase | `JsonPropertyName` | Schema 否;运行时是 | +| 枚举值 | CLR 成员名 | 当前 Schema 不支持 `JsonStringEnumMemberName` | Schema 否;运行时是 | +| 枚举默认值 | 当前错误使用底层数字字符串 | 无 | 否 | +| 多态鉴别属性名 | `JsonPolymorphic.TypeDiscriminatorPropertyName`,默认 `$type` | `JsonPolymorphic` | 通过 Roslyn 特性读取 | +| 多态鉴别值 | `JsonDerivedType`;缺失时当前错误回退类型名 | `JsonDerivedType` | 通过 Roslyn 特性读取 | +| 资源名称 | 方法名转 PascalCase | `McpServerResource.Name` | 否,也不应读取 | +| 默认资源 URI 方法段 | 方法名转 kebab-case | `McpServerResource.UriTemplate` | 否,也不应读取 | +| 默认资源 URI 参数占位 | 原始参数名 | 显式 URI 模板 | 否 | + +统一约束不应理解为“所有名称使用同一种大小写”。正确目标是: + +- MCP 标识符由 MCP 特性和本库默认规则控制。 +- JSON payload 名称和值由一个明确且一致的 JSON 契约控制。 +- Schema 与运行时必须使用同一个 wire name。 + +## 7. 修复后的期望效果 + +### 7.1 参数文档 + +- 主表与 6 个 `ToolParameterType` 一一对应。 +- `Injected` 只列一次,在说明中区分可空与非空服务缺失行为。 +- `Parameter` 和 `InputObject` 下另列 JSON 类型行为。 +- 枚举明确描述为字符串 Schema,而不是整数 Schema。 + +### 7.2 枚举输入 + +对于: + +```csharp +public enum EchoOptions +{ + PlainText, + + [JsonStringEnumMemberName("jsonObject")] + JsonObject, +} + +public void Echo(EchoOptions options = EchoOptions.JsonObject) +``` + +期望 Schema: + +```json +{ + "type": "string", + "enum": ["PlainText", "jsonObject"], + "default": "jsonObject" +} +``` + +并满足: + +```text +Schema enum 值 += 默认值使用的值 += 实际反序列化接受的值 += 对象 JSON 序列化写出的值 += 直接枚举返回使用的值(若决定返回 wire value) +``` + +### 7.3 对象属性 + +对于任何参与输入或结构化输出的对象: + +```text +Schema properties 中的名称 += 实际 JsonTypeInfo 使用的属性名称 +``` + +至少保证: + +- 默认 camelCase 契约一致。 +- `JsonPropertyName` 一致。 +- 若允许其他 `PropertyNamingPolicy`,Schema 必须真实遵循;否则应在构建期或启动时明确拒绝/警告。 + +### 7.4 多态输入 + +- discriminator 属性名与实际 JSON 一致。 +- discriminator 常量保留 string/int JSON 类型。 +- 不凭空创造运行时不存在的 discriminator。 +- Schema 列出的每个派生类型都能被实际上下文反序列化。 + +### 7.5 诊断 + +对于无法可靠生成一致 Schema 的情况,不应静默输出错误 Schema。应考虑增加 Analyzer 诊断: + +- 枚举默认值没有对应成员。 +- 枚举别名导致默认值歧义。 +- 不支持或无法推断的枚举转换器。 +- `[Flags]` 契约未明确。 +- 多态派生类型没有显式 discriminator。 +- Schema 命名策略与业务上下文不兼容。 + +诊断编号和严重级别需要在实现时另行设计。 + +## 8. 架构方案与待决策项 + +### 8.1 方案 A:固定本库 JSON wire contract + +规则示例: + +- 对象属性固定 camelCase,允许 `JsonPropertyName`。 +- 枚举固定字符串成员名,允许 `JsonStringEnumMemberName`。 +- 多态必须使用显式 `JsonPolymorphic`/`JsonDerivedType`。 +- 业务上下文必须兼容这些规则。 + +优点: + +- 保留编译期 Schema。 +- AOT 友好。 +- 改动相对集中。 + +缺点: + +- 不能完整支持任意 `JsonSourceGenerationOptions` 和自定义转换器。 +- 需要验证或诊断业务上下文是否兼容。 + +### 8.2 方案 B:运行时从实际 `JsonTypeInfo` 构造 Schema + +工具定义生成时取得服务器实际业务 `JsonSerializerContext`,从 `JsonTypeInfo`/`JsonPropertyInfo` 获取真实名称和契约。 + +优点: + +- Schema 最有机会与实际运行时一致。 +- 可以支持更多命名策略和转换器。 + +缺点: + +- 需要修改 `IMcpServerTool.GetToolDefinition` 或工具注册模型。 +- 需要设计 `JsonTypeInfo` 到 JSON Schema 的转换。 +- 任意转换器仍未必能公开枚举的有限值集合。 +- 同一工具在不同服务器上下文中可能产生不同 Schema,需要缓存策略。 + +### 8.3 方案 C:混合方案 + +- 编译期生成结构和默认契约。 +- 运行时使用实际 `JsonTypeInfo` 修正属性名、枚举值等可获取信息。 +- 无法推断时产生诊断或要求显式覆盖。 + +这是长期最灵活的方案,但复杂度最高。 + +### 8.4 当前推荐 + +分两阶段: + +1. **第一阶段,修复确定性缺陷** + - 修复枚举默认值。 + - 支持 `JsonStringEnumMemberName`。 + - 修复直接枚举和枚举集合返回。 + - 修复整数多态 discriminator。 + - 禁止凭空生成 discriminator。 + - 明确并测试固定 camelCase + 显式属性覆盖契约。 + - 更新文档。 + +2. **第二阶段,评估运行时 Schema** + - 决定是否支持任意业务命名策略。 + - 若支持,调整 `IMcpServerTool.GetToolDefinition` 和 Schema 构造架构。 + - 若不支持,为不兼容上下文提供可靠诊断。 + +## 9. 建议实现顺序 + +1. 为 Analyzer 建立可直接断言生成源码或工具 Schema 的测试基础设施。 +2. 用回归测试固定当前 `"1"` 缺陷。 +3. 重构 `EnumJsonValueInfo`,至少保存: + - CLR 成员名。 + - wire name。 + - 底层常量值。 + - 描述。 + - 是否为别名/是否存在歧义所需的信息。 +4. 修复枚举默认值并处理未命名值、别名。 +5. 支持 `JsonStringEnumMemberName`,同时更新 enum、default 和描述提示。 +6. 统一直接枚举及枚举集合返回。 +7. 处理 `[Flags]`。 +8. 修复多态 discriminator 的缺失和 JSON 类型。 +9. 决定对象命名策略架构,并增加对应测试/诊断。 +10. 更新简体中文文档。 +11. 同步英文和繁体中文文档。 +12. 运行完整构建和测试。 + +## 10. 测试清单 + +当前测试项目引用 Analyzer 作为编译期 Analyzer,但没有发现专门的 GeneratorDriver、生成源码快照或 Schema 单元测试。下一会话应优先补这一层,避免只通过端到端服务器测试间接验证。 + +至少需要: + +### 10.1 枚举 Schema + +- 普通枚举参数。 +- 可空枚举参数。 +- 枚举集合和数组。 +- 对象中的枚举属性。 +- `InputObject` 中的枚举属性。 +- 结构化输出对象中的枚举属性。 +- `JsonStringEnumMemberName`。 +- 默认值为第一个成员。 +- 默认值为非零成员。 +- 自定义底层数值。 +- 未命名值。 +- 重复底层值别名。 +- `[Flags]` 单值和组合值。 + +### 10.2 枚举运行时 + +- 字符串枚举输入成功。 +- Schema 声明值与实际接受值一致。 +- 不兼容数字输入的行为被明确固定。 +- 直接枚举返回。 +- 可空枚举返回。 +- 枚举集合返回。 +- 对象中的枚举序列化。 + +### 10.3 属性命名 + +- 默认 camelCase。 +- `JsonPropertyName`。 +- record 主构造函数属性。 +- 嵌套对象。 +- `InputObject`。 +- 结构化输出。 +- 非 camelCase `PropertyNamingPolicy` 的支持或诊断行为。 + +### 10.4 多态 + +- 字符串 discriminator。 +- 整数 discriminator。 +- 自定义 discriminator 属性名。 +- 缺失显式 discriminator。 +- 未知 discriminator。 +- Schema 中 `const` 的 JSON 类型。 + +### 10.5 文档示例 + +- 文档中的 `EchoOptions` 示例应有可验证测试。 +- 文档声明的 Schema 和实际生成结果保持一致。 + +## 11. 已有证据与复现方式 + +### 11.1 生成代码复现 + +使用: + +```powershell +dotnet build samples\DotNetCampus.SampleMcpServer\DotNetCampus.SampleMcpServer.csproj ` + --no-restore ` + -p:EmitCompilerGeneratedFiles=true ` + -p:CompilerGeneratedFilesOutputPath=<工作区内临时目录> +``` + +然后检查生成的: + +```text +DotNetCampus.ModelContextProtocol.Generators.McpServerToolGenerator/ +DotNetCampus.SampleMcpServer.McpTools/ +SimpleTool.Echo.cs +``` + +已经实测生成: + +```csharp +Type = JsonSerializer.SerializeToElement("string", jsonContext.String), +Default = JsonSerializer.SerializeToElement("1", jsonContext.String), +Enum = [ "PlainText", "JsonObject" ], +``` + +同次构建成功,无编译错误。这说明该问题不会被现有构建流程发现。 + +### 11.2 关键源码 + +- 枚举映射为 string: + `src/DotNetCampus.ModelContextProtocol.Analyzer/CodeAnalysis/JsonSchemaType.cs:110-122` +- 枚举成员提取: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/EnumJsonValueInfo.cs` +- Schema enum 和描述生成: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/JsonPropertySchemaInfo.cs:131-145` + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/JsonPropertySchemaInfo.cs:184-209` +- 默认值生成: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/JsonPropertySchemaInfo.cs:321-339` +- 输入反序列化: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:168-208` +- 返回值生成: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:281-321` + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs:360-391` +- 属性名生成: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/ToolSymbolExtensions.cs:92-110` + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/ToolSymbolExtensions.cs:136-154` +- 多态信息: + `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/PolymorphicTypeInfo.cs` +- Schema 数据结构: + `src/DotNetCampus.ModelContextProtocol/CompilerServices/CompiledJsonSchema.cs` +- 业务 JSON context 合并: + `src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs:15-56` +- 默认内部工具 JSON 配置: + `src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs:85-135` + +### 11.3 示例证据 + +- 枚举参数和默认值: + `samples/DotNetCampus.SampleMcpServer/McpTools/SimpleTool.cs:14-25` +- `EchoOptions`: + `samples/DotNetCampus.SampleMcpServer/McpTools/SimpleTool.cs:69-85` +- 示例 context 已注册枚举且启用字符串枚举: + `samples/DotNetCampus.SampleMcpServer/McpTools/McpToolJsonContext.cs:5-19` +- 枚举返回和枚举集合返回: + `samples/DotNetCampus.SampleMcpServer/McpTools/OutputTool.cs:93-100` + `samples/DotNetCampus.SampleMcpServer/McpTools/OutputTool.cs:124-131` + +### 11.4 文档证据 + +- 参数分类: + `docs/zh-hans/Tools.md:76-96` +- 返回值分类: + `docs/zh-hans/Tools.md:160-190` +- JSON serializer 说明: + `docs/zh-hans/Tools.md:286-310` +- Quick Start context: + `docs/zh-hans/QuickStart.md:9-50` + +## 12. 下一会话必须阅读的参考资料 + +### 12.1 仓库内资料 + +按顺序阅读: + +1. 本文:`docs/input-output-issues.md` +2. `docs/zh-hans/Tools.md` +3. `docs/zh-hans/QuickStart.md` +4. `src/DotNetCampus.ModelContextProtocol/CompilerServices/ToolParameterAttribute.cs` +5. `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/ToolSymbolExtensions.cs` +6. `src/DotNetCampus.ModelContextProtocol.Analyzer/CodeAnalysis/JsonSchemaTypeInfo.cs` +7. `src/DotNetCampus.ModelContextProtocol.Analyzer/CodeAnalysis/JsonSchemaType.cs` +8. `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/EnumJsonValueInfo.cs` +9. `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/JsonPropertySchemaInfo.cs` +10. `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/McpServerToolGeneratingModel.cs` +11. `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/SourceBuilders/McpServerToolSourceBuilder.cs` +12. `src/DotNetCampus.ModelContextProtocol.Analyzer/Generators/Models/PolymorphicTypeInfo.cs` +13. `src/DotNetCampus.ModelContextProtocol/CompilerServices/CompiledJsonSchema.cs` +14. `src/DotNetCampus.ModelContextProtocol/CompilerServices/McpJsonContext.cs` +15. `src/DotNetCampus.ModelContextProtocol/Servers/IMcpServerTool.cs` +16. `src/DotNetCampus.ModelContextProtocol/Servers/McpServerRequestHandlers.cs` +17. `samples/DotNetCampus.SampleMcpServer/McpTools/SimpleTool.cs` +18. `samples/DotNetCampus.SampleMcpServer/McpTools/McpToolJsonContext.cs` +19. `samples/DotNetCampus.SampleMcpServer/McpTools/OutputTool.cs` +20. `samples/DotNetCampus.SampleMcpServer/McpTools/PolymorphicTool.cs` + +### 12.2 官方资料 + +- System.Text.Json 属性命名和枚举命名: + https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/customize-properties +- System.Text.Json 多态序列化: + https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/polymorphism +- `JsonStringEnumMemberNameAttribute`: + https://learn.microsoft.com/dotnet/api/system.text.json.serialization.jsonstringenummembernameattribute +- `JsonSourceGenerationOptionsAttribute`: + https://learn.microsoft.com/dotnet/api/system.text.json.serialization.jsonsourcegenerationoptionsattribute +- JSON Schema Draft 2020-12: + https://json-schema.org/draft/2020-12 +- MCP Tools 规范: + https://modelcontextprotocol.io/specification/2025-11-25/server/tools + +## 13. 下一会话开始时的建议步骤 + +1. 检查 `git status`,不要覆盖用户已有修改。 +2. 重新运行第 11.1 节生成代码复现,确认基线未变化。 +3. 先增加失败的回归测试,不要先改实现。 +4. 明确第 8 节的架构选择,至少确认第一阶段范围。 +5. 首个实现目标应是: + - `EchoOptions.JsonObject` 的 Schema 默认值从 `"1"` 变成正确 wire value。 + - 增加对应生成源码或 Schema 断言。 +6. 再处理 `JsonStringEnumMemberName` 和枚举返回值。 +7. 多态和任意命名策略应独立提交或至少独立测试,避免一次改动过大。 +8. 最后更新三种语言文档。 + +## 14. 相邻风险:尚未完成审计 + +下一会话不要把本文当成完整的 `System.Text.Json` Schema conformance 结论。建议后续继续检查: + +- `JsonIgnore` 是否被 Schema 正确排除。 +- `JsonInclude` 和字段序列化。 +- `JsonRequired`。 +- C# `required` 与可空性的组合;“必须出现”和“允许 null”不应混为一谈。 +- record 主构造函数参数的 required 行为。 +- 只读/只写属性。 +- `JsonConverter` 应用于类型或属性时的 Schema。 +- 字典值并非 `string` 时的 Schema。 +- `JsonNumberHandling` 允许字符串数字时与 Schema 的关系。 +- `JsonUnmappedMemberHandling` / `additionalProperties`。 +- 集合具体类型和可反序列化能力。 +- `DateTime`、`Guid`、`Uri` 等字符串格式类型是否需要 `format`。 +- 多态基类上的未知派生类型处理设置。 +- 输出 Schema 的 required 语义是否与实际序列化必然出现的属性一致。 + +这些项目目前是**待审计项**,除非下一会话用源码和测试确认,否则不要直接标记为已确认缺陷。 + +## 15. 工作区注意事项 + +编写本文时,工作区原本已经有用户修改: + +```text +M docs/tasks/01-schema-conformance-baseline/plan.md +``` + +该修改与本文任务无关,不要回退或覆盖。 + +本文创建时没有修改生产代码、测试代码或现有简体中文文档。 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 页面或 `