From 359bbc98801683750e974237b752d0a592354a76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:12:34 +0000 Subject: [PATCH 1/6] Initial plan From 4197c8fd88772b3ca0b1725a36f08eb23abf4474 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:40:19 +0000 Subject: [PATCH 2/6] Add tool name to structured logging for tool calls Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../McpSessionHandler.cs | 48 ++++++++++++---- tests/Common/Utils/MockLoggerProvider.cs | 2 + .../McpServerBuilderExtensionsToolsTests.cs | 56 ++++++++++++++++++- 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 749486e4b..5cda42ed6 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -436,7 +436,8 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc // Now that the request has been sent, register for cancellation. If we registered before, // a cancellation request could arrive before the server knew about that request ID, in which // case the server could ignore it. - LogRequestSentAwaitingResponse(EndpointName, request.Method, request.Id); + string? target = GetRequestTarget(request); + LogRequestSentAwaitingResponse(EndpointName, request.Method, request.Id, toolName: target); JsonRpcMessage? response; using (var registration = RegisterCancellation(cancellationToken, request)) { @@ -445,7 +446,7 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc if (response is JsonRpcError error) { - LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code); + LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code, toolName: target); throw new McpException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code); } @@ -458,11 +459,11 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc if (_logger.IsEnabled(LogLevel.Trace)) { - LogRequestResponseReceivedSensitive(EndpointName, request.Method, success.Result?.ToJsonString() ?? "null"); + LogRequestResponseReceivedSensitive(EndpointName, request.Method, success.Result?.ToJsonString() ?? "null", toolName: target); } else { - LogRequestResponseReceived(EndpointName, request.Method); + LogRequestResponseReceived(EndpointName, request.Method, toolName: target); } return success; @@ -763,6 +764,29 @@ private static TimeSpan GetElapsed(long startingTimestamp) => return null; } + /// + /// Extracts the target identifier (tool name, prompt name, or resource URI) from a request. + /// + /// The JSON-RPC request. + /// The target identifier if available; otherwise, null. + private static string? GetRequestTarget(JsonRpcRequest request) + { + if (request.Params is not JsonObject paramsObj) + { + return null; + } + + return request.Method switch + { + RequestMethods.ToolsCall => GetStringProperty(paramsObj, "name"), + RequestMethods.PromptsGet => GetStringProperty(paramsObj, "name"), + RequestMethods.ResourcesRead => GetStringProperty(paramsObj, "uri"), + RequestMethods.ResourcesSubscribe => GetStringProperty(paramsObj, "uri"), + RequestMethods.ResourcesUnsubscribe => GetStringProperty(paramsObj, "uri"), + _ => null + }; + } + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} message processing canceled.")] private partial void LogEndpointMessageProcessingCanceled(string endpointName); @@ -778,8 +802,8 @@ private static TimeSpan GetElapsed(long startingTimestamp) => [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received request for unknown request ID '{RequestId}'.")] private partial void LogNoRequestFoundForMessageWithId(string endpointName, RequestId requestId); - [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} request failed for method '{Method}': {ErrorMessage} ({ErrorCode}).")] - private partial void LogSendingRequestFailed(string endpointName, string method, string errorMessage, int errorCode); + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} request failed for method '{Method}' (tool: '{ToolName}'): {ErrorMessage} ({ErrorCode}).")] + private partial void LogSendingRequestFailed(string endpointName, string method, string errorMessage, int errorCode, string? toolName = null); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received invalid response for method '{Method}'.")] private partial void LogSendingRequestInvalidResponseType(string endpointName, string method); @@ -793,11 +817,11 @@ private static TimeSpan GetElapsed(long startingTimestamp) => [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} canceled request '{RequestId}' per client notification. Reason: '{Reason}'.")] private partial void LogRequestCanceled(string endpointName, RequestId requestId, string? reason); - [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} Request response received for method {method}")] - private partial void LogRequestResponseReceived(string endpointName, string method); + [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} Request response received for method {method} (tool: '{ToolName}')")] + private partial void LogRequestResponseReceived(string endpointName, string method, string? toolName = null); - [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} Request response received for method {method}. Response: '{Response}'.")] - private partial void LogRequestResponseReceivedSensitive(string endpointName, string method, string response); + [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} Request response received for method {method} (tool: '{ToolName}'). Response: '{Response}'.")] + private partial void LogRequestResponseReceivedSensitive(string endpointName, string method, string response, string? toolName = null); [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} read {MessageType} message from channel.")] private partial void LogMessageRead(string endpointName, string messageType); @@ -814,8 +838,8 @@ private static TimeSpan GetElapsed(long startingTimestamp) => [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received request for method '{Method}', but no handler is available.")] private partial void LogNoHandlerFoundForRequest(string endpointName, string method); - [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} waiting for response to request '{RequestId}' for method '{Method}'.")] - private partial void LogRequestSentAwaitingResponse(string endpointName, string method, RequestId requestId); + [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} waiting for response to request '{RequestId}' for method '{Method}' (tool: '{ToolName}').")] + private partial void LogRequestSentAwaitingResponse(string endpointName, string method, RequestId requestId, string? toolName = null); [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} sending message.")] private partial void LogSendingMessage(string endpointName); diff --git a/tests/Common/Utils/MockLoggerProvider.cs b/tests/Common/Utils/MockLoggerProvider.cs index 14a0f401a..a56c71036 100644 --- a/tests/Common/Utils/MockLoggerProvider.cs +++ b/tests/Common/Utils/MockLoggerProvider.cs @@ -6,6 +6,7 @@ namespace ModelContextProtocol.Tests.Utils; public class MockLoggerProvider() : ILoggerProvider { public ConcurrentQueue<(string Category, LogLevel LogLevel, EventId EventId, string Message, Exception? Exception)> LogMessages { get; } = []; + public ConcurrentQueue<(string Category, LogLevel LogLevel, EventId EventId, string Message, Exception? Exception, object? State)> LogMessagesWithState { get; } = []; public ILogger CreateLogger(string categoryName) { @@ -22,6 +23,7 @@ public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { mockProvider.LogMessages.Enqueue((category, logLevel, eventId, formatter(state, exception), exception)); + mockProvider.LogMessagesWithState.Enqueue((category, logLevel, eventId, formatter(state, exception), exception, state)); } public bool IsEnabled(LogLevel logLevel) => true; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 97fd3e330..1c7f0a599 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -20,13 +20,20 @@ namespace ModelContextProtocol.Tests.Configuration; public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase { + private MockLoggerProvider _mockLoggerProvider = new(); + public McpServerBuilderExtensionsToolsTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { + // Configure LoggerFactory to use Debug level and add MockLoggerProvider + LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => + { + builder.AddProvider(XunitLoggerProvider); + builder.AddProvider(_mockLoggerProvider); + builder.SetMinimumLevel(LogLevel.Debug); + }); } - private MockLoggerProvider _mockLoggerProvider = new(); - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { mcpServerBuilder @@ -733,6 +740,51 @@ await client.SendNotificationAsync( await Assert.ThrowsAnyAsync(async () => await invokeTask); } + [Fact] + public async Task ToolName_Captured_In_Structured_Logging() + { + await using McpClient client = await CreateMcpClientForServer(); + + // Call a tool that will succeed + var result = await client.CallToolAsync( + "echo", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify that the tool name is captured in structured logging + // The LogMessagesWithState should contain log entries with tool name in the state + var allLogs = _mockLoggerProvider.LogMessagesWithState.ToList(); + TestOutputHelper.WriteLine($"Total logs captured: {allLogs.Count}"); + foreach (var log in allLogs) + { + TestOutputHelper.WriteLine($"Log: Category={log.Category}, Level={log.LogLevel}, Message={log.Message}"); + } + + var relevantLogs = allLogs + .Where(m => m.Category == "ModelContextProtocol.Client.McpClient" && + m.Message.Contains("tools/call")) + .ToList(); + + TestOutputHelper.WriteLine($"Relevant logs: {relevantLogs.Count}"); + Assert.NotEmpty(relevantLogs); + + // Check that at least one log entry has the tool name in its structured state + bool foundToolName = relevantLogs.Any(log => + { + if (log.State is IReadOnlyList> stateList) + { + return stateList.Any(kvp => + kvp.Key == "ToolName" && + kvp.Value?.ToString() == "echo"); + } + return false; + }); + + Assert.True(foundToolName, "Tool name 'echo' was not found in structured logging state"); + } + [McpServerToolType] public sealed class EchoTool(ObjectWithId objectFromDI) { From c7db00a1b872c72f12436b426f25866c44e77c96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:43:39 +0000 Subject: [PATCH 3/6] Clean up test and add helpful comments Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../McpServerBuilderExtensionsToolsTests.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 1c7f0a599..c539e805f 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -755,22 +755,17 @@ public async Task ToolName_Captured_In_Structured_Logging() // Verify that the tool name is captured in structured logging // The LogMessagesWithState should contain log entries with tool name in the state - var allLogs = _mockLoggerProvider.LogMessagesWithState.ToList(); - TestOutputHelper.WriteLine($"Total logs captured: {allLogs.Count}"); - foreach (var log in allLogs) - { - TestOutputHelper.WriteLine($"Log: Category={log.Category}, Level={log.LogLevel}, Message={log.Message}"); - } - - var relevantLogs = allLogs + var relevantLogs = _mockLoggerProvider.LogMessagesWithState .Where(m => m.Category == "ModelContextProtocol.Client.McpClient" && m.Message.Contains("tools/call")) .ToList(); - TestOutputHelper.WriteLine($"Relevant logs: {relevantLogs.Count}"); Assert.NotEmpty(relevantLogs); // Check that at least one log entry has the tool name in its structured state + // This demonstrates how users can extract the tool name from TState in a custom ILoggerProvider + // The State object is IReadOnlyList> which contains + // structured logging parameters like "ToolName", "Method", "EndpointName", etc. bool foundToolName = relevantLogs.Any(log => { if (log.State is IReadOnlyList> stateList) From 1dbda9ce54169ebe662bc6563484890b9a218e2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:47:54 +0000 Subject: [PATCH 4/6] Add test for tool error case with structured logging Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../McpServerBuilderExtensionsToolsTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index c539e805f..ba47d1b8d 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -780,6 +780,46 @@ public async Task ToolName_Captured_In_Structured_Logging() Assert.True(foundToolName, "Tool name 'echo' was not found in structured logging state"); } + [Fact] + public async Task ToolName_Captured_In_Structured_Logging_OnToolError() + { + await using McpClient client = await CreateMcpClientForServer(); + + // Call a tool that will error - note that tool errors are returned as CallToolResult with IsError=true, + // not thrown as exceptions per the MCP spec + var result = await client.CallToolAsync( + "throw_exception", + cancellationToken: TestContext.Current.CancellationToken); + + // Verify the tool error was returned properly + Assert.NotNull(result); + Assert.True(result.IsError); + + // Verify that the tool name is captured in structured logging + // even when the tool encounters an error + var relevantLogs = _mockLoggerProvider.LogMessagesWithState + .Where(m => m.LogLevel == LogLevel.Debug && + (m.Message.Contains("waiting for response") || m.Message.Contains("response received")) && + m.Message.Contains("tools/call")) + .ToList(); + + Assert.NotEmpty(relevantLogs); + + // Check that at least one log entry has the tool name in its structured state + bool foundToolName = relevantLogs.Any(log => + { + if (log.State is IReadOnlyList> stateList) + { + return stateList.Any(kvp => + kvp.Key == "ToolName" && + kvp.Value?.ToString() == "throw_exception"); + } + return false; + }); + + Assert.True(foundToolName, "Tool name 'throw_exception' was not found in structured logging state"); + } + [McpServerToolType] public sealed class EchoTool(ObjectWithId objectFromDI) { From 836730e9046d9edba2681d65e82271d9b6ff45b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:47:23 +0000 Subject: [PATCH 5/6] Merge main to resolve conflicts with McpProtocolException changes Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .github/copilot-instructions.md | 348 ++++++++++++++++++ .github/workflows/ci-build-test.yml | 54 ++- .github/workflows/ci-code-coverage.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/release.yml | 8 +- Makefile | 15 +- README.md | 4 +- docs/concepts/elicitation/elicitation.md | 20 +- docs/concepts/logging/logging.md | 31 +- docs/concepts/progress/progress.md | 28 +- global.json | 4 +- samples/EverythingServer/Program.cs | 2 +- .../AuthorizationFilterSetup.cs | 6 +- .../HttpServerTransportOptions.cs | 10 +- src/ModelContextProtocol.Core/McpException.cs | 60 +-- .../McpProtocolException.cs | 73 ++++ .../McpSessionHandler.cs | 18 +- .../Server/McpServer.Methods.cs | 14 +- .../Server/McpServerImpl.cs | 58 +-- .../Server/McpServerPrompt.cs | 6 +- .../Server/McpServerResource.cs | 6 +- .../Server/McpServerTool.cs | 4 - .../AuthorizeAttributeTests.cs | 34 +- .../HttpServerIntegrationTests.cs | 2 +- .../MapMcpStreamableHttpTests.cs | 5 +- .../Program.cs | 28 +- .../Program.cs | 20 +- .../ClientIntegrationTests.cs | 2 +- .../McpServerBuilderExtensionsFilterTests.cs | 33 +- .../McpServerBuilderExtensionsPromptsTests.cs | 10 +- ...cpServerBuilderExtensionsResourcesTests.cs | 10 +- .../McpServerBuilderExtensionsToolsTests.cs | 6 +- .../DiagnosticTests.cs | 2 +- .../ModelContextProtocol.Tests.csproj | 1 - .../Protocol/ElicitationTypedTests.cs | 6 +- .../Server/McpServerTests.cs | 104 ++++++ 36 files changed, 761 insertions(+), 275 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 src/ModelContextProtocol.Core/McpProtocolException.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..dc8f0ca6d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,348 @@ +# Copilot Instructions for MCP C# SDK + +This repository contains the official C# SDK for the Model Context Protocol (MCP), enabling .NET applications to implement and interact with MCP clients and servers. + +## Critical: Always Build and Test + +**ALWAYS build and run tests before declaring any task complete or making a pull request.** + +When making code changes: +1. **Build first**: Run `dotnet build` to ensure the code compiles without errors +2. **Run tests**: Execute `dotnet test` to verify all tests pass +3. **Fix issues**: Address any build errors or test failures before proceeding +4. **Verify iteratively**: Build and test frequently during development, not just at the end +5. **Check warnings**: Treat warnings as errors - the build is configured with `TreatWarningsAsErrors=true` + +**Never skip these steps.** Even small changes can have unexpected impacts. A passing build and test suite is the minimum bar for any code change. + +## Project Overview + +The SDK consists of three main packages: +- **ModelContextProtocol.Core** - Client and low-level server APIs with minimal dependencies +- **ModelContextProtocol** - The main package with hosting and dependency injection extensions and which references ModelContextProtocol.Core +- **ModelContextProtocol.AspNetCore** - HTTP-based MCP server implementations for ASP.NET Core, referencing ModelContextProtocol + +## C# Coding Standards + +### Language Features +- Use **file-scoped namespaces** for all C# files +- Enable **implicit usings** and **nullable reference types** +- Use **preview language features** (LangVersion: preview) +- Treat warnings as errors + +### Code Style +- Follow the conventions in `.editorconfig` +- Use clear, descriptive XML documentation comments for public APIs +- Follow async/await patterns consistently +- Use file-scoped namespaces: `namespace ModelContextProtocol.Client;` + +### Naming Conventions +- Use `McpClient`, `McpServer`, `McpSession` for MCP-related classes (capitalize MCP) +- Prefix MCP-specific types with `Mcp` (e.g., `McpException`, `McpEndpoint`) +- Use descriptive names for parameters with `[Description("...")]` attributes when exposing to MCP + +## Architecture Patterns + +### Dependency Injection +- Use Microsoft.Extensions.DependencyInjection patterns +- Register services with `.AddMcpServer()` and `.AddMcpClient()` extension methods +- Support both builder patterns and options configuration + +### JSON Serialization +- Use `System.Text.Json` exclusively for all JSON operations +- Use `McpJsonUtilities.DefaultOptions` for consistent serialization settings across the SDK +- Support source generation for Native AOT compatibility via `McpJsonUtilities` source generators +- Set `JsonIgnoreCondition.WhenWritingNull` for optional properties to minimize payload size +- Use `JsonSerializerDefaults.Web` for camelCase property naming +- Protocol types are decorated with `[JsonSerializable]` attributes for AOT support +- Custom converters: `CustomizableJsonStringEnumConverter` for flexible enum serialization + +### Async Patterns +- All I/O operations should be async +- Use `ValueTask` for hot paths that may complete synchronously +- Always accept `CancellationToken` parameters for async operations +- Name parameters consistently: `cancellationToken` + +### MCP Protocol +- Follow the MCP specification at https://spec.modelcontextprotocol.io/ ([specification docs](https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/docs/specification)) +- Use JSON-RPC 2.0 for message transport +- Support all standard MCP capabilities (e.g. tools, prompts, resources, sampling) +- Implement proper error handling with `McpException` and `McpErrorCode` + +### Error Handling +- Throw `McpException` for MCP protocol-level errors with appropriate `McpErrorCode` +- Use standard error codes: `InvalidRequest`, `MethodNotFound`, `InvalidParams`, `InternalError` +- Let domain exceptions bubble up and convert to `InternalError` at transport boundary +- Include detailed error messages in exception `Message` property for debugging +- Errors are automatically converted to JSON-RPC error responses by the server infrastructure + +## Testing + +### Test Organization +- Unit tests in `tests/ModelContextProtocol.Tests` for core functionality +- Integration tests in `tests/ModelContextProtocol.AspNetCore.Tests` for HTTP/SSE transports +- Shared test utilities in `tests/Common/Utils/` +- Test servers in `tests/ModelContextProtocol.Test*Server/` for integration scenarios +- Filter manual tests with `[Trait("Execution", "Manual")]` - these require external dependencies + +### Test Infrastructure and Helpers +- **LoggedTest**: Base class for tests that need logging output captured to xUnit test output + - Provides `ILoggerFactory` and `ITestOutputHelper` for test logging + - Use when debugging or when tests need to verify log output +- **TestServerTransport**: In-memory transport for testing client-server interactions without network I/O +- **MockLoggerProvider**: For capturing and asserting on log messages +- **XunitLoggerProvider**: Routes `ILogger` output to xUnit's `ITestOutputHelper` +- **KestrelInMemoryTransport** (AspNetCore.Tests): In-memory Kestrel connection for HTTP transport testing without network stack + +### Test Best Practices +- Inherit from `LoggedTest` for tests needing logging infrastructure +- Use `TestServerTransport` for in-memory client-server testing +- Mock external dependencies (filesystem, HTTP clients) rather than calling real services +- Use `CancellationTokenSource` with timeouts to prevent hanging tests +- Dispose resources properly (servers, clients, transports) using `IDisposable` or `await using` +- Run tests with: `dotnet test --filter '(Execution!=Manual)'` + +## Build and Development + +### Build Commands +- **Restore**: `dotnet restore` +- **Build**: `dotnet build` +- **Test**: `dotnet test` +- **Clean**: `dotnet clean` + +### Development Workflow +**Critical**: Always follow this workflow when making changes: +1. Make code changes +2. Build immediately: `dotnet build` - fix any compilation errors +3. Run tests: `dotnet test` - fix any test failures +4. Repeat steps 1-3 iteratively as you develop +5. Only after successful build and tests should you consider the change complete + +Do not skip or defer building and testing. These are mandatory steps for every code change, no matter how small. + +### SDK Requirements +- The repo currently requires the .NET SDK 10.0 to build and run tests. +- Target frameworks: .NET 10.0, .NET 9.0, .NET 8.0, .NET Standard 2.0 +- Support Native AOT compilation + +### Project Structure +- Source code: `src/` +- Tests: `tests/` +- Samples: `samples/` +- Documentation: `docs/` +- Build artifacts: `artifacts/` (not committed) + +## Key Types and Architectural Layers + +The SDK is organized into distinct architectural layers, each with specific responsibilities: + +### Protocol Layer (DTO Types) +- Located in `ModelContextProtocol.Core/Protocol/` +- Contains Data Transfer Objects (DTOs) for the MCP specification +- All protocol types follow JSON-RPC 2.0 conventions +- Key types: + - **JsonRpcMessage** (abstract base): Represents any JSON-RPC message (request, response, notification, error) + - **JsonRpcRequest**, **JsonRpcResponse**, **JsonRpcNotification**: Concrete message types + - **Tool**, **Prompt**, **Resource**: MCP primitive definitions + - **CallToolRequestParams**, **GetPromptRequestParams**, **ReadResourceRequestParams**: Request parameter types + - **ClientCapabilities**, **ServerCapabilities**: Capability negotiation types + - **Implementation**: Server/client identification metadata + +### JSON-RPC Implementation +- Built-in JSON-RPC 2.0 implementation for MCP communication +- **JsonRpcMessage.Converter**: Polymorphic converter that deserializes messages into correct types based on structure +- **JsonRpcMessageContext**: Transport-specific metadata (transport reference, execution context, authenticated user) +- Message routing handled automatically by session implementations +- Error responses generated via **McpException** with **McpErrorCode** enumeration + +### Transport Abstraction +- **ITransport**: Core abstraction for bidirectional communication + - Provides `MessageReader` (ChannelReader) for incoming messages + - `SendMessageAsync()` for outgoing messages + - `SessionId` property for multi-session scenarios +- **IClientTransport**: Client-side abstraction that establishes connections and returns ITransport +- **TransportBase**: Base class for transport implementations with common functionality + +### Transport Implementations +Two primary transport implementations with different invariants: + +1. **Stdio-based transports** (`StdioServerTransport`, `StdioClientTransport`): + - Single-session, process-bound communication + - Uses standard input/output streams + - No session IDs (returns null) + - Automatic lifecycle tied to process + +2. **HTTP-based transports**: + - **SseResponseStreamTransport**: Server-Sent Events for server-to-client streaming + - Unidirectional (server → client) event stream + - Client posts messages to separate endpoint (e.g., `/message`) + - Supports multiple concurrent sessions via SessionId + - **StreamableHttpServerTransport**: Bidirectional HTTP with streaming + - Request/response model with streamed progress updates + - Session management for concurrent connections + +### Session Layer +- **McpSession** (abstract base): Core bidirectional communication for clients and servers + - Manages JSON-RPC request/response correlation + - Handles notification routing + - Provides `SendRequestAsync()`, `SendNotificationAsync()`, `RegisterNotificationHandler()` + - Properties: `SessionId`, `NegotiatedProtocolVersion` + +- **McpClient** (extends McpSession): Client-side MCP implementation + - Connects to servers via `CreateAsync(IClientTransport)` + - Exposes `ServerCapabilities`, `ServerInfo`, `ServerInstructions` + - Methods: `ListToolsAsync()`, `CallToolAsync()`, `ListPromptsAsync()`, `GetPromptAsync()`, etc. + +- **McpServer** (extends McpSession): Server-side MCP implementation + - Configured via `McpServerOptions` and `IMcpServerBuilder` + - Primitives registered as services: `McpServerTool`, `McpServerPrompt`, `McpServerResource` + - Handles incoming requests through `McpServer.Methods.cs` + - Supports filters via `McpRequestFilter` for cross-cutting concerns + +### Serialization Architecture +- **McpJsonUtilities.DefaultOptions**: Singleton JsonSerializerOptions for all MCP types + - Hardcoded to use source-generated serialization for JSON-RPC messages (Native AOT compatible) + - Source generation defined in `McpJsonUtilities` via `[JsonSerializable]` attributes + - Includes Microsoft.Extensions.AI types via chained TypeInfoResolver + +- **User-defined types** (tool parameters, return values): + - Accept custom `JsonSerializerOptions` via `McpServerToolCreateOptions.SerializerOptions` + - Default to `McpJsonUtilities.DefaultOptions` if not specified + - Can use reflection-based serialization or custom source generators + +- **Enum handling**: `CustomizableJsonStringEnumConverter` for flexible enum serialization + +## Architecture and Design Patterns + +### Server Implementation Architecture +- **McpServer** is the core server implementation in `ModelContextProtocol.Core/Server/` +- **IMcpServerBuilder** pattern provides fluent API for configuring servers via DI +- Server primitives (tools, prompts, resources) are discovered via reflection using attributes +- Support both attribute-based registration (`WithTools()`) and instance-based (`WithTools(target)`) +- Use `McpServerFactory` to create server instances with configured options + +### Tool/Prompt/Resource Discovery +- Tools, prompts, and resources use attribute-based discovery: `[McpServerTool]`, `[McpServerPrompt]`, `[McpServerResource]` +- Type-level attributes (`[McpServerToolType]`, etc.) mark classes containing server primitives +- Discovery supports both static and instance methods (public and non-public) +- For Native AOT compatibility, use generic `WithTools()` methods instead of reflection-based variants +- `AIFunctionMcpServerTool`, `AIFunctionMcpServerPrompt`, and `AIFunctionMcpServerResource` wrap `AIFunction` for integration with Microsoft.Extensions.AI + +### Request Processing Pipeline +- Requests flow through `McpServer.Methods.cs` which handles JSON-RPC message routing +- Use `McpRequestFilter` for cross-cutting concerns (logging, auth, validation) +- `RequestContext` provides access to current request state and services +- `RequestServiceProvider` enables scoped dependency injection per request +- Filters can short-circuit request processing or transform requests/responses + +### Transport Layer Abstraction +- Transport implementations handle message serialization and connection management +- Core transports: `StdioServerTransport`, `StreamServerTransport`, `SseResponseStreamTransport`, `StreamableHttpServerTransport` +- Transports must implement bidirectional JSON-RPC message exchange +- SSE (Server-Sent Events) transport for unidirectional server→client streaming +- Streamable HTTP for request/response with streamed progress updates + +## Implementation Patterns + +### Tool Implementation +Tools are methods marked with `[McpServerTool]`: +```csharp +[McpServerToolType] +public class MyTools +{ + [McpServerTool, Description("Tool description")] + public static async Task MyTool( + [Description("Parameter description")] string param, + CancellationToken cancellationToken) + { + // Implementation - use Description attributes for parameter documentation + // Return string, TextContent, ImageContent, EmbeddedResource, or arrays of these + } +} +``` +- Tools support dependency injection in constructors for instance methods +- Parameters are automatically deserialized from JSON using `System.Text.Json` +- Use `[Description]` attributes on parameters to generate tool schemas +- Return types: `string`, `TextContent`, `ImageContent`, `EmbeddedResource`, or collections of content types + +### Prompt Implementation +Prompts return `ChatMessage` or arrays thereof: +```csharp +[McpServerPromptType] +public static class MyPrompts +{ + [McpServerPrompt, Description("Prompt description")] + public static ChatMessage MyPrompt([Description("Parameter description")] string content) => + new(ChatRole.User, $"Prompt template: {content}"); +} +``` +- Prompts can accept arguments to customize generated messages +- Return single `ChatMessage` or `ChatMessage[]` for multi-turn prompts +- Use `ChatRole.User`, `ChatRole.Assistant`, or `ChatRole.System` appropriately + +### Resource Implementation +Resources provide access to data with URI templates: +```csharp +[McpServerResourceType] +public class MyResources +{ + [McpServerResource("file:///{path}"), Description("Reads file content")] + public static async Task ReadFile(string path, CancellationToken cancellationToken) + { + // Resource URI matching uses UriTemplate syntax + // Extract parameters from URI and return content + } +} +``` +- Use URI templates to define resource paths with parameters +- Resources support subscription for dynamic content updates +- Return content types similar to tools + +### Filters and Middleware +Implement `McpRequestFilter` for request/response interception: +```csharp +public class LoggingFilter : McpRequestFilter +{ + public override async ValueTask InvokeAsync(RequestContext context, Func next) + { + // Pre-processing + await next(); // Call next filter or handler + // Post-processing + } +} +``` +- Filters execute in registration order +- Can short-circuit by not calling `next()` +- Access request context, services, and can modify responses +- Use for cross-cutting concerns: logging, auth, validation, caching + +## OpenTelemetry Integration + +- The SDK includes built-in observability support +- Use ActivitySource name: `"Experimental.ModelContextProtocol"` +- Use Meter name: `"Experimental.ModelContextProtocol"` +- Export traces and metrics using OTLP when appropriate + +## Documentation + +- API documentation is generated using DocFX +- Conceptual documentation is in `docs/concepts/` +- Keep README files up to date in package directories +- Use `///` XML comments for all public APIs +- Include `` sections for detailed explanations + +## Security + +- Never commit secrets or API keys +- Use environment variables for sensitive configuration +- Support authentication mechanisms (OAuth, API keys) +- Validate all user inputs +- Follow secure coding practices per SECURITY.md + +## Additional Notes + +- This is a preview SDK; breaking changes may occur +- Follow the Model Context Protocol specification +- Integrate with Microsoft.Extensions.AI patterns where applicable +- Support both stdio and HTTP transports +- Maintain compatibility with the broader MCP ecosystem diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index 3d9aaf4a9..6bd40fc3d 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -15,6 +15,7 @@ on: - "*.sln" - "*.props" - "Makefile" + - "global.json" - "src/**" - "tests/**" - "samples/**" @@ -34,64 +35,57 @@ jobs: runs-on: ${{ matrix.os }} steps: - - name: Clone the repo + - name: 📥 Clone the repo uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up .NET + - name: 🔧 Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: dotnet-version: | - 10.0.x + 10.0.100-rc.1.25451.107 9.0.x # NetFX testing on non-Windows requires mono - - name: Setup Mono + - name: 🔧 Setup Mono if: runner.os == 'Linux' run: sudo apt-get install -y mono-devel - - name: Setup Mono on macOS + - name: 🔧 Setup Mono on macOS if: runner.os == 'macOS' run: brew install mono - - name: Set up Node.js + - name: 🔧 Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: '20' - - name: Install dependencies for tests + - name: 📦 Install dependencies for tests run: npm install @modelcontextprotocol/server-everything - - name: Install dependencies for tests + - name: 📦 Install dependencies for tests run: npm install @modelcontextprotocol/server-memory - - name: Build - run: dotnet build --configuration ${{ matrix.configuration }} - - - name: Pack - run: dotnet pack --configuration ${{ matrix.configuration }} - - - name: Test - run: >- - dotnet test - --filter '(Execution!=Manual)' - --no-build - --configuration ${{ matrix.configuration }} - --logger "trx" - --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" - --blame - --blame-hang-timeout 7m - --blame-crash - --results-directory testresults - --collect "XPlat Code Coverage" -- RunConfiguration.CollectSourceInformation=true - - - name: Upload test results artifact + - name: 🏗️ Build + run: make build CONFIGURATION=${{ matrix.configuration }} + + - name: 🧪 Test + run: make test CONFIGURATION=${{ matrix.configuration }} + + - name: 📦 Pack + if: matrix.configuration == 'Release' + run: make pack CONFIGURATION=${{ matrix.configuration }} + + - name: 📚 Generate docs + run: make generate-docs CONFIGURATION=${{ matrix.configuration }} + + - name: 📤 Upload test results artifact if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: testresults-${{ matrix.os }}-${{ matrix.configuration }} - path: testresults/** + path: artifacts/testresults/** publish-coverage: if: github.actor != 'dependabot[bot]' diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index a7b42f466..e8ffbad2f 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -15,7 +15,7 @@ jobs: uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: dotnet-version: | - 10.0.x + 10.0.100-rc.1.25451.107 9.0.x - name: Download test results diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 871a2fbeb..63e0bbd9a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,7 +33,7 @@ jobs: uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: dotnet-version: | - 10.0.x + 10.0.100-rc.1.25451.107 9.0.x - name: Generate documentation diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbaae18ca..730b68791 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: dotnet-version: | - 10.0.x + 10.0.100-rc.1.25451.107 9.0.x - name: Build @@ -79,7 +79,7 @@ jobs: uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: dotnet-version: | - 10.0.x + 10.0.100-rc.1.25451.107 9.0.x - name: Pack @@ -108,7 +108,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: - dotnet-version: 10.0.x + dotnet-version: 10.0.100-rc.1.25451.107 - name: Download build artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 @@ -163,7 +163,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: - dotnet-version: 10.0.x + dotnet-version: 10.0.100-rc.1.25451.107 - name: Download build artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 diff --git a/Makefile b/Makefile index c2a0a012f..b9d0ffdd7 100644 --- a/Makefile +++ b/Makefile @@ -21,16 +21,21 @@ test: build --configuration $(CONFIGURATION) \ --filter '(Execution!=Manual)' \ --blame \ + --blame-crash \ + --blame-hang-timeout 7m \ --diag "$(ARTIFACT_PATH)/diag.txt" \ --logger "trx" \ - --collect "Code Coverage;Format=cobertura" \ - --results-directory $(ARTIFACT_PATH)/test-results \ + --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" \ + --collect "XPlat Code Coverage" \ + --results-directory $(ARTIFACT_PATH)/testresults \ -- \ RunConfiguration.CollectSourceInformation=true -generate-docs: clean restore - dotnet build --no-restore --configuration Release - dotnet docfx $(DOCS_PATH)/docfx.json +pack: restore + dotnet pack --no-restore --configuration $(CONFIGURATION) + +generate-docs: build + dotnet docfx $(DOCS_PATH)/docfx.json --warningsAsErrors true serve-docs: generate-docs dotnet docfx serve $(ARTIFACT_PATH)/_site --port 8080 diff --git a/README.md b/README.md index 4c87ba9bd..3099dfcd3 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ McpServerOptions options = new() { if (request.Params.Arguments?.TryGetValue("message", out var message) is not true) { - throw new McpException("Missing required argument 'message'"); + throw new McpProtocolException("Missing required argument 'message'", McpErrorCode.InvalidParams); } return ValueTask.FromResult(new CallToolResult @@ -216,7 +216,7 @@ McpServerOptions options = new() }); } - throw new McpException($"Unknown tool: '{request.Params?.Name}'"); + throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidRequest); } } }; diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index ebda0979a..2d9d43c9f 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -11,12 +11,9 @@ The **elicitation** feature allows servers to request additional information fro ### Server Support for Elicitation -Servers request structured data from users with the [ElicitAsync] extension method on [IMcpServer]. -The C# SDK registers an instance of [IMcpServer] with the dependency injection container, -so tools can simply add a parameter of type [IMcpServer] to their method signature to access it. - -[ElicitAsync]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.McpServerExtensions.html#ModelContextProtocol_Server_McpServerExtensions_ElicitAsync_ModelContextProtocol_Server_IMcpServer_ModelContextProtocol_Protocol_ElicitRequestParams_System_Threading_CancellationToken_ -[IMcpServer]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.IMcpServer.html +Servers request structured data from users with the extension method on . +The C# SDK registers an instance of with the dependency injection container, +so tools can simply add a parameter of type to their method signature to access it. The MCP Server must specify the schema of each input value it is requesting from the user. Only primitive types (string, number, boolean) are supported for elicitation requests. @@ -31,10 +28,7 @@ The following example demonstrates how a server could request a boolean response ### Client Support for Elicitation -Elicitation is an optional feature so clients declare their support for it in their capabilities as part of the `initialize` request. In the MCP C# SDK, this is done by configuring an [ElicitationHandler] in the [McpClientOptions]: - -[ElicitationHandler]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Protocol.ElicitationCapability.html#ModelContextProtocol_Protocol_ElicitationCapability_ElicitationHandler -[McpClientOptions]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Client.McpClientOptions.html +Elicitation is an optional feature so clients declare their support for it in their capabilities as part of the `initialize` request. In the MCP C# SDK, this is done by configuring an in the : [!code-csharp[](samples/client/Program.cs?name=snippet_McpInitialize)] @@ -42,10 +36,8 @@ The ElicitationHandler is an asynchronous method that will be called when the se The ElicitationHandler must request input from the user and return the data in a format that matches the requested schema. This will be highly dependent on the client application and how it interacts with the user. -If the user provides the requested information, the ElicitationHandler should return an [ElicitResult] with the action set to "accept" and the content containing the user's input. -If the user does not provide the requested information, the ElicitationHandler should return an [ElicitResult] with the action set to "reject" and no content. - -[ElicitResult]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Protocol.ElicitResult.html +If the user provides the requested information, the ElicitationHandler should return an with the action set to "accept" and the content containing the user's input. +If the user does not provide the requested information, the ElicitationHandler should return an [ with the action set to "reject" and no content. Below is an example of how a console application might handle elicitation requests. Here's an example implementation: diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md index 411a61b1c..742aa9fc1 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -49,53 +49,36 @@ Servers built with the C# SDK always declare the logging capability. Doing so do to send log messages -- only allows it. Note that stateless MCP servers may not be capable of sending log messages as there may not be an open connection to the client on which the log messages could be sent. -The C# SDK provides an extension method [WithSetLoggingLevelHandler] on [IMcpServerBuilder] to allow the +The C# SDK provides an extension method on to allow the server to perform any special logic it wants to perform when a client sets the logging level. However, the -SDK already takes care of setting the [LoggingLevel] in the [IMcpServer], so most servers will not need to +SDK already takes care of setting the in the , so most servers will not need to implement this. -[IMcpServer]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.IMcpServer.html -[IMcpServerBuilder]: https://modelcontextprotocol.github.io/csharp-sdk/api/Microsoft.Extensions.DependencyInjection.IMcpServerBuilder.html -[WithSetLoggingLevelHandler]: https://modelcontextprotocol.github.io/csharp-sdk/api/Microsoft.Extensions.DependencyInjection.McpServerBuilderExtensions.html#Microsoft_Extensions_DependencyInjection_McpServerBuilderExtensions_WithSetLoggingLevelHandler_Microsoft_Extensions_DependencyInjection_IMcpServerBuilder_System_Func_ModelContextProtocol_Server_RequestContext_ModelContextProtocol_Protocol_SetLevelRequestParams__System_Threading_CancellationToken_System_Threading_Tasks_ValueTask_ModelContextProtocol_Protocol_EmptyResult___ -[LoggingLevel]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.IMcpServer.html#ModelContextProtocol_Server_IMcpServer_LoggingLevel - -MCP Servers using the MCP C# SDK can obtain an [ILoggerProvider] from the IMcpServer [AsClientLoggerProvider] extension method, -and from that can create an [ILogger] instance for logging messages that should be sent to the MCP client. +MCP Servers using the MCP C# SDK can obtain an [ILoggerProvider](https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.iloggerprovider) from the IMcpServer extension method, +and from that can create an [ILogger](https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.ilogger) instance for logging messages that should be sent to the MCP client. [!code-csharp[](samples/server/Tools/LoggingTools.cs?name=snippet_LoggingConfiguration)] -[ILoggerProvider]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.iloggerprovider -[AsClientLoggerProvider]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.McpServerExtensions.html#ModelContextProtocol_Server_McpServerExtensions_AsClientLoggerProvider_ModelContextProtocol_Server_IMcpServer_ -[ILogger]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.ilogger - ### Client support for logging When the server indicates that it supports logging, clients should configure the logging level to specify which messages the server should send to the client. -Clients should check if the server supports logging by checking the [Logging] property of the [ServerCapabilities] field of [IMcpClient]. - -[IMcpClient]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Client.IMcpClient.html -[ServerCapabilities]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Client.IMcpClient.html#ModelContextProtocol_Client_IMcpClient_ServerCapabilities -[Logging]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Protocol.ServerCapabilities.html#ModelContextProtocol_Protocol_ServerCapabilities_Logging +Clients should check if the server supports logging by checking the property of the field of . [!code-csharp[](samples/client/Program.cs?name=snippet_LoggingCapabilities)] If the server supports logging, the client should set the level of log messages it wishes to receive with -the [SetLoggingLevel] method on [IMcpClient]. If the client does not set a logging level, the server might choose +the method on . If the client does not set a logging level, the server might choose to send all log messages or none -- this is not specified in the protocol -- so it is important that the client sets a logging level to ensure it receives the desired log messages and only those messages. The `loggingLevel` set by the client is an MCP logging level. See the [Logging Levels](#logging-levels) section above for the mapping between MCP and .NET logging levels. -[SetLoggingLevel]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Client.McpClientExtensions.html#ModelContextProtocol_Client_McpClientExtensions_SetLoggingLevel_ModelContextProtocol_Client_IMcpClient_Microsoft_Extensions_Logging_LogLevel_System_Threading_CancellationToken_ - [!code-csharp[](samples/client/Program.cs?name=snippet_LoggingLevel)] -Lastly, the client must configure a notification handler for [NotificationMethods.LoggingMessageNotification] notifications. +Lastly, the client must configure a notification handler for notifications. The following example simply writes the log messages to the console. -[NotificationMethods.LoggingMessageNotification]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Protocol.NotificationMethods.html#ModelContextProtocol_Protocol_NotificationMethods_LoggingMessageNotification - [!code-csharp[](samples/client/Program.cs?name=snippet_LoggingHandler)] diff --git a/docs/concepts/progress/progress.md b/docs/concepts/progress/progress.md index ccdf9f19c..f23f3e895 100644 --- a/docs/concepts/progress/progress.md +++ b/docs/concepts/progress/progress.md @@ -17,17 +17,13 @@ This project illustrates the common case of a server tool that performs a long-r ### Server Implementation -When processing a request, the server can use the [sendNotificationAsync] extension method of [IMcpServer] to send progress updates, +When processing a request, the server can use the extension method of to send progress updates, specifying `"notifications/progress"` as the notification method name. -The C# SDK registers an instance of [IMcpServer] with the dependency injection container, -so tools can simply add a parameter of type [IMcpServer] to their method signature to access it. -The parameters passed to [sendNotificationAsync] should be an instance of [ProgressNotificationParams], which includes the current progress, total steps, and an optional message. +The C# SDK registers an instance of with the dependency injection container, +so tools can simply add a parameter of type to their method signature to access it. +The parameters passed to should be an instance of , which includes the current progress, total steps, and an optional message. -[sendNotificationAsync]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.McpEndpointExtensions.html#ModelContextProtocol_McpEndpointExtensions_SendNotificationAsync_ModelContextProtocol_IMcpEndpoint_System_String_System_Threading_CancellationToken_ -[IMcpServer]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.IMcpServer.html -[ProgressNotificationParams]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Protocol.ProgressNotificationParams.html - -The server must verify that the caller provided a `progressToken` in the request and include it in the call to [sendNotificationAsync]. The following example demonstrates how a server can send a progress notification: +The server must verify that the caller provided a `progressToken` in the request and include it in the call to . The following example demonstrates how a server can send a progress notification: [!code-csharp[](samples/server/Tools/LongRunningTools.cs?name=snippet_SendProgress)] @@ -38,10 +34,7 @@ Note that servers are not required to support progress tracking, so clients shou In the MCP C# SDK, clients can specify a `progressToken` in the request parameters when calling a tool method. The client should also provide a notification handler to process "notifications/progress" notifications. -There are two way to do this. The first is to register a notification handler using the [RegisterNotificationHandler] method on the [IMcpClient] instance. A handler registered this way will receive all progress notifications sent by the server. - -[IMcpClient]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Client.IMcpClient.html -[RegisterNotificationHandler]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.IMcpEndpoint.html#ModelContextProtocol_IMcpEndpoint_RegisterNotificationHandler_System_String_System_Func_ModelContextProtocol_Protocol_JsonRpcNotification_System_Threading_CancellationToken_System_Threading_Tasks_ValueTask__ +There are two way to do this. The first is to register a notification handler using the method on the instance. A handler registered this way will receive all progress notifications sent by the server. ```csharp mcpClient.RegisterNotificationHandler(NotificationMethods.ProgressNotification, @@ -57,13 +50,10 @@ mcpClient.RegisterNotificationHandler(NotificationMethods.ProgressNotification, }).ConfigureAwait(false); ``` -The second way is to pass a [Progress``] instance to the tool method. [Progress``] is a standard .NET type that provides a way to receive progress updates. -For the purposes of MCP progress notifications, `T` should be [ProgressNotificationValue]. -The MCP C# SDK will automatically handle progress notifications and report them through the [Progress``] instance. +The second way is to pass a [`Progress`](https://learn.microsoft.com/dotnet/api/system.progress-1) instance to the tool method. `Progress` is a standard .NET type that provides a way to receive progress updates. +For the purposes of MCP progress notifications, `T` should be . +The MCP C# SDK will automatically handle progress notifications and report them through the `Progress` instance. This notification handler will only receive progress updates for the specific request that was made, rather than all progress notifications from the server. -[Progress``]: https://learn.microsoft.com/en-us/dotnet/api/system.progress-1 -[ProgressNotificationValue]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.ProgressNotificationValue.html - [!code-csharp[](samples/client/Program.cs?name=snippet_ProgressHandler)] diff --git a/global.json b/global.json index 58c475f9f..1e2b44cb4 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.100-rc.1", - "rollForward": "minor", + "version": "10.0.100-rc.1.25451.107", + "rollForward": "disable", "allowPrerelease": true } } diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index b976bcc0a..a18a29461 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -123,7 +123,7 @@ await ctx.Server.SampleAsync([ { if (ctx.Params?.Level is null) { - throw new McpException("Missing required argument 'level'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'level'", McpErrorCode.InvalidParams); } _minimumLoggingLevel = ctx.Params.Level; diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs index 2cfb74d09..dce594e2e 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -78,7 +78,7 @@ private void ConfigureCallToolFilter(McpServerOptions options) var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); if (!authResult.Succeeded) { - throw new McpException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); + throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); } context.Items[AuthorizationFilterInvokedKey] = true; @@ -170,7 +170,7 @@ private void ConfigureReadResourceFilter(McpServerOptions options) var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); if (!authResult.Succeeded) { - throw new McpException("Access forbidden: This resource requires authorization.", McpErrorCode.InvalidRequest); + throw new McpProtocolException("Access forbidden: This resource requires authorization.", McpErrorCode.InvalidRequest); } return await next(context, cancellationToken); @@ -230,7 +230,7 @@ private void ConfigureGetPromptFilter(McpServerOptions options) var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); if (!authResult.Succeeded) { - throw new McpException("Access forbidden: This prompt requires authorization.", McpErrorCode.InvalidRequest); + throw new McpProtocolException("Access forbidden: This prompt requires authorization.", McpErrorCode.InvalidRequest); } return await next(context, cancellationToken); diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 8d71f5166..aa7659dc1 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -23,13 +23,13 @@ public class HttpServerTransportOptions public Func? RunSessionHandler { get; set; } /// - /// Gets or sets whether the server should run in a stateless mode that does not require all requests for a given session - /// to arrive to the same ASP.NET Core application process. + /// Gets or sets whether the server should run in a stateless mode which allows for load balancing without session affinity. /// /// - /// If , the "/sse" endpoint will be disabled, and client information will be round-tripped as part - /// of the "MCP-Session-Id" header instead of stored in memory. Unsolicited server-to-client messages and all server-to-client - /// requests are also unsupported, because any responses may arrive at another ASP.NET Core application process. + /// If , is called once for every request for each request, + /// the "/sse" endpoint will be disabled, and the "MCP-Session-Id" header will not be used. + /// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses + /// may arrive at another ASP.NET Core application process. /// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests. /// Defaults to . /// diff --git a/src/ModelContextProtocol.Core/McpException.cs b/src/ModelContextProtocol.Core/McpException.cs index 3831dd688..6b2342ea1 100644 --- a/src/ModelContextProtocol.Core/McpException.cs +++ b/src/ModelContextProtocol.Core/McpException.cs @@ -1,14 +1,21 @@ +using ModelContextProtocol.Protocol; + namespace ModelContextProtocol; /// /// Represents an exception that is thrown when an Model Context Protocol (MCP) error occurs. /// /// -/// This exception is used to represent failures to do with protocol-level concerns, such as invalid JSON-RPC requests, -/// invalid parameters, or internal errors. It is not intended to be used for application-level errors. -/// or from a may be -/// propagated to the remote endpoint; sensitive information should not be included. If sensitive details need -/// to be included, a different exception type should be used. +/// The from a may be propagated to the remote +/// endpoint; sensitive information should not be included. If sensitive details need to be included, +/// a different exception type should be used. +/// +/// This exception type can be thrown by MCP tools or tool call filters to propagate detailed error messages +/// from when a tool execution fails via a . +/// For non-tool calls, this exception controls the message propogated via a . +/// +/// is a derived type that can be used to also specify the +/// that should be used for the resulting . /// public class McpException : Exception { @@ -28,46 +35,13 @@ public McpException(string message) : base(message) } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified error message and + /// a reference to the inner exception that is the cause of this exception. /// /// The message that describes the error. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// The exception that is the cause of the current exception, or a null + /// reference if no inner exception is specified. public McpException(string message, Exception? innerException) : base(message, innerException) { } - - /// - /// Initializes a new instance of the class with a specified error message and JSON-RPC error code. - /// - /// The message that describes the error. - /// A . - public McpException(string message, McpErrorCode errorCode) : this(message, null, errorCode) - { - } - - /// - /// Initializes a new instance of the class with a specified error message, inner exception, and JSON-RPC error code. - /// - /// The message that describes the error. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - /// A . - public McpException(string message, Exception? innerException, McpErrorCode errorCode) : base(message, innerException) - { - ErrorCode = errorCode; - } - - /// - /// Gets the error code associated with this exception. - /// - /// - /// This property contains a standard JSON-RPC error code as defined in the MCP specification. Common error codes include: - /// - /// -32700: Parse error - Invalid JSON received - /// -32600: Invalid request - The JSON is not a valid Request object - /// -32601: Method not found - The method does not exist or is not available - /// -32602: Invalid params - Invalid method parameters - /// -32603: Internal error - Internal JSON-RPC error - /// - /// - public McpErrorCode ErrorCode { get; } = McpErrorCode.InternalError; -} \ No newline at end of file +} diff --git a/src/ModelContextProtocol.Core/McpProtocolException.cs b/src/ModelContextProtocol.Core/McpProtocolException.cs new file mode 100644 index 000000000..d6bc7b632 --- /dev/null +++ b/src/ModelContextProtocol.Core/McpProtocolException.cs @@ -0,0 +1,73 @@ +namespace ModelContextProtocol; + +/// +/// Represents an exception that is thrown when an Model Context Protocol (MCP) error occurs. +/// +/// +/// This exception is used to represent failures to do with protocol-level concerns, such as invalid JSON-RPC requests, +/// invalid parameters, or internal errors. It is not intended to be used for application-level errors. +/// or from a may be +/// propagated to the remote endpoint; sensitive information should not be included. If sensitive details need +/// to be included, a different exception type should be used. +/// +public sealed class McpProtocolException : McpException +{ + /// + /// Initializes a new instance of the class. + /// + public McpProtocolException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public McpProtocolException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public McpProtocolException(string message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and JSON-RPC error code. + /// + /// The message that describes the error. + /// A . + public McpProtocolException(string message, McpErrorCode errorCode) : this(message, null, errorCode) + { + } + + /// + /// Initializes a new instance of the class with a specified error message, inner exception, and JSON-RPC error code. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// A . + public McpProtocolException(string message, Exception? innerException, McpErrorCode errorCode) : base(message, innerException) + { + ErrorCode = errorCode; + } + + /// + /// Gets the error code associated with this exception. + /// + /// + /// This property contains a standard JSON-RPC error code as defined in the MCP specification. Common error codes include: + /// + /// -32700: Parse error - Invalid JSON received + /// -32600: Invalid request - The JSON is not a valid Request object + /// -32601: Method not found - The method does not exist or is not available + /// -32602: Invalid params - Invalid method parameters + /// -32603: Internal error - Internal JSON-RPC error + /// + /// + public McpErrorCode ErrorCode { get; } = McpErrorCode.InternalError; +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 5cda42ed6..e47354742 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -181,11 +181,17 @@ ex is OperationCanceledException && { LogRequestHandlerException(EndpointName, request.Method, ex); - JsonRpcErrorDetail detail = ex is McpException mcpe ? + JsonRpcErrorDetail detail = ex is McpProtocolException mcpProtocolException ? new() { - Code = (int)mcpe.ErrorCode, - Message = mcpe.Message, + Code = (int)mcpProtocolException.ErrorCode, + Message = mcpProtocolException.Message, + } : ex is McpException mcpException ? + new() + { + + Code = (int)McpErrorCode.InternalError, + Message = mcpException.Message, } : new() { @@ -336,7 +342,7 @@ private void HandleMessageWithId(JsonRpcMessage message, JsonRpcMessageWithId me if (!_requestHandlers.TryGetValue(request.Method, out var handler)) { LogNoHandlerFoundForRequest(EndpointName, request.Method); - throw new McpException($"Method '{request.Method}' is not available.", McpErrorCode.MethodNotFound); + throw new McpProtocolException($"Method '{request.Method}' is not available.", McpErrorCode.MethodNotFound); } LogRequestHandlerCalled(EndpointName, request.Method); @@ -447,7 +453,7 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc if (response is JsonRpcError error) { LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code, toolName: target); - throw new McpException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code); + throw new McpProtocolException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code); } if (response is JsonRpcResponse success) @@ -641,7 +647,7 @@ private static void AddExceptionTags(ref TagList tags, Activity? activity, Excep } int? intErrorCode = - (int?)((e as McpException)?.ErrorCode) is int errorCode ? errorCode : + (int?)((e as McpProtocolException)?.ErrorCode) is int errorCode ? errorCode : e is JsonException ? (int)McpErrorCode.ParseError : null; diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 0bae663ba..0d255c5b0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -291,7 +291,7 @@ public async ValueTask> ElicitAsync( /// The type of the schema being built. /// The serializer options to use. /// The built request schema. - /// + /// private static ElicitRequestParams.RequestSchema BuildRequestSchema(Type type, JsonSerializerOptions serializerOptions) { var schema = new ElicitRequestParams.RequestSchema(); @@ -301,7 +301,7 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(Type type, J if (typeInfo.Kind != JsonTypeInfoKind.Object) { - throw new McpException($"Type '{type.FullName}' is not supported for elicitation requests."); + throw new McpProtocolException($"Type '{type.FullName}' is not supported for elicitation requests."); } foreach (JsonPropertyInfo pi in typeInfo.Properties) @@ -319,33 +319,33 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(Type type, J /// The type to create the schema for. /// The serializer options to use. /// The created primitive schema definition. - /// Thrown when the type is not supported. + /// Thrown when the type is not supported. private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) { if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) { - throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests. Nullable types are not supported."); + throw new McpProtocolException($"Type '{type.FullName}' is not a supported property type for elicitation requests. Nullable types are not supported."); } var typeInfo = serializerOptions.GetTypeInfo(type); if (typeInfo.Kind != JsonTypeInfoKind.None) { - throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); + throw new McpProtocolException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); } var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions); if (!TryValidateElicitationPrimitiveSchema(jsonElement, type, out var error)) { - throw new McpException(error); + throw new McpProtocolException(error); } var primitiveSchemaDefinition = jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); if (primitiveSchemaDefinition is null) - throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); + throw new McpProtocolException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); return primitiveSchemaDefinition; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index c152d3a0a..8e264741b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -288,7 +288,7 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null && listResourcesHandler ??= (static async (_, __) => new ListResourcesResult()); listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult()); - readResourceHandler ??= (static async (request, _) => throw new McpException($"Unknown resource URI: '{request.Params?.Uri}'", McpErrorCode.InvalidParams)); + readResourceHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", McpErrorCode.InvalidParams)); subscribeHandler ??= (static async (_, __) => new EmptyResult()); unsubscribeHandler ??= (static async (_, __) => new EmptyResult()); var listChanged = resourcesCapability?.ListChanged; @@ -452,7 +452,7 @@ private void ConfigurePrompts(McpServerOptions options) ServerCapabilities.Prompts = new(); listPromptsHandler ??= (static async (_, __) => new ListPromptsResult()); - getPromptHandler ??= (static async (request, _) => throw new McpException($"Unknown prompt: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); + getPromptHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown prompt: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); var listChanged = promptsCapability?.ListChanged; // Handle tools provided via DI by augmenting the handlers to incorporate them. @@ -540,7 +540,7 @@ private void ConfigureTools(McpServerOptions options) ServerCapabilities.Tools = new(); listToolsHandler ??= (static async (_, __) => new ListToolsResult()); - callToolHandler ??= (static async (request, _) => throw new McpException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); + callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); var listChanged = toolsCapability?.ListChanged; // Handle tools provided via DI by augmenting the handlers to incorporate them. @@ -580,7 +580,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.ListToolsFilters); callToolHandler = BuildFilterPipeline(callToolHandler, options.Filters.CallToolFilters, handler => - (request, cancellationToken) => + async (request, cancellationToken) => { // Initial handler that sets MatchedPrimitive if (request.Params?.Name is { } toolName && tools is not null && @@ -589,37 +589,23 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) request.MatchedPrimitive = tool; } - return handler(request, cancellationToken); - }, handler => - async (request, cancellationToken) => - { - // Final handler that provides exception handling only for tool execution - // Only wrap tool execution in try-catch, not tool resolution - if (request.MatchedPrimitive is McpServerTool) + try { - try - { - return await handler(request, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) when (e is not OperationCanceledException) - { - ToolCallError(request.Params?.Name ?? string.Empty, e); - - string errorMessage = e is McpException ? - $"An error occurred invoking '{request.Params?.Name}': {e.Message}" : - $"An error occurred invoking '{request.Params?.Name}'."; - - return new() - { - IsError = true, - Content = [new TextContentBlock { Text = errorMessage }], - }; - } + return await handler(request, cancellationToken); } - else + catch (Exception e) when (e is not OperationCanceledException and not McpProtocolException) { - // For unmatched tools, let exceptions bubble up as protocol errors - return await handler(request, cancellationToken).ConfigureAwait(false); + ToolCallError(request.Params?.Name ?? string.Empty, e); + + string errorMessage = e is McpException ? + $"An error occurred invoking '{request.Params?.Name}': {e.Message}" : + $"An error occurred invoking '{request.Params?.Name}'."; + + return new() + { + IsError = true, + Content = [new TextContentBlock { Text = errorMessage }], + }; } }); @@ -735,16 +721,10 @@ private void SetHandler( private static McpRequestHandler BuildFilterPipeline( McpRequestHandler baseHandler, List> filters, - McpRequestFilter? initialHandler = null, - McpRequestFilter? finalHandler = null) + McpRequestFilter? initialHandler = null) { var current = baseHandler; - if (finalHandler is not null) - { - current = finalHandler(current); - } - for (int i = filters.Count - 1; i >= 0; i--) { current = filters[i](current); diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs index a7fa0e242..dafdd7f7c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs @@ -105,9 +105,13 @@ namespace ModelContextProtocol.Server; /// Converted to a list of instances derived from the with . /// /// -/// of +/// of /// Converted to a list of instances derived from all of the instances with . /// +/// +/// +/// Returned directly without modification. +/// /// /// /// Other returned types will result in an being thrown. diff --git a/src/ModelContextProtocol.Core/Server/McpServerResource.cs b/src/ModelContextProtocol.Core/Server/McpServerResource.cs index 2a43e3349..00d89c774 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResource.cs @@ -90,7 +90,7 @@ namespace ModelContextProtocol.Server; /// Wrapped in a list containing the single . /// /// -/// +/// /// Converted to a list containing a single . /// /// @@ -113,6 +113,10 @@ namespace ModelContextProtocol.Server; /// of /// Converted to a list containing a , one for each . /// +/// +/// +/// Returned directly without modification. +/// /// /// /// Other returned types will result in an being thrown. diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index 4136f5913..6948ea912 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -110,10 +110,6 @@ namespace ModelContextProtocol.Server; /// Returned as a single-item list. /// /// -/// of -/// Each is converted to a object with its text set to the string value. -/// -/// /// of /// Each is converted to a object using . /// diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs index 84d1c1a79..073b2fd18 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -36,7 +36,7 @@ public async Task Authorize_Tool_RequiresAuthentication() var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.CallToolAsync( "authorized_tool", new Dictionary { ["message"] = "test" }, @@ -101,7 +101,7 @@ public async Task AuthorizeWithRoles_Tool_RequiresAdminRole() var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.CallToolAsync( "admin_tool", new Dictionary { ["message"] = "test" }, @@ -188,7 +188,7 @@ public async Task Authorize_Prompt_RequiresAuthentication() var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.GetPromptAsync( "authorized_prompt", new Dictionary { ["message"] = "test" }, @@ -235,7 +235,7 @@ public async Task Authorize_Resource_RequiresAuthentication() var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( "resource://authorized", cancellationToken: TestContext.Current.CancellationToken)); @@ -277,7 +277,7 @@ public async Task ListTools_WithoutAuthFilters_ThrowsInvalidOperationException() await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools()); var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): An error occurred.", exception.Message); @@ -289,21 +289,23 @@ log.Exception is InvalidOperationException && } [Fact] - public async Task CallTool_WithoutAuthFilters_ThrowsInvalidOperationException() + public async Task CallTool_WithoutAuthFilters_ReturnsError() { _mockLoggerProvider.LogMessages.Clear(); await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools()); var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => - await client.CallToolAsync( + var toolResult = await client.CallToolAsync( "authorized_tool", new Dictionary { ["message"] = "test" }, - cancellationToken: TestContext.Current.CancellationToken)); + cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("Request failed (remote): An error occurred.", exception.Message); + Assert.True(toolResult.IsError); + + var errorContent = Assert.IsType(Assert.Single(toolResult.Content)); + Assert.Equal("An error occurred invoking 'authorized_tool'.", errorContent.Text); Assert.Contains(_mockLoggerProvider.LogMessages, log => - log.LogLevel == LogLevel.Warning && + log.LogLevel == LogLevel.Error && log.Exception is InvalidOperationException && log.Exception.Message.Contains("Authorization filter was not invoked for tools/call operation") && log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called")); @@ -316,7 +318,7 @@ public async Task ListPrompts_WithoutAuthFilters_ThrowsInvalidOperationException await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts()); var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): An error occurred.", exception.Message); @@ -334,7 +336,7 @@ public async Task GetPrompt_WithoutAuthFilters_ThrowsInvalidOperationException() await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts()); var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.GetPromptAsync( "authorized_prompt", new Dictionary { ["message"] = "test" }, @@ -355,7 +357,7 @@ public async Task ListResources_WithoutAuthFilters_ThrowsInvalidOperationExcepti await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources()); var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): An error occurred.", exception.Message); @@ -373,7 +375,7 @@ public async Task ReadResource_WithoutAuthFilters_ThrowsInvalidOperationExceptio await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources()); var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( "resource://authorized", cancellationToken: TestContext.Current.CancellationToken)); @@ -393,7 +395,7 @@ public async Task ListResourceTemplates_WithoutAuthFilters_ThrowsInvalidOperatio await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources()); var client = await ConnectAsync(); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): An error occurred.", exception.Message); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 5e3a654f9..78acaeb5e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -240,7 +240,7 @@ public async Task GetPrompt_Sse_NonExistent_ThrowsException() // act await using var client = await GetClientAsync(); - await Assert.ThrowsAsync(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 9f50793ce..04eceb8d7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; using ModelContextProtocol.Client; +using System.Collections.Concurrent; namespace ModelContextProtocol.AspNetCore.Tests; @@ -148,7 +149,7 @@ public async Task SseMode_Works_WithSseEndpoint() [Fact] public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitialization() { - var protocolVersionHeaderValues = new List(); + var protocolVersionHeaderValues = new ConcurrentQueue(); Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -160,7 +161,7 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia { if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-protocol-version"])) { - protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]); + protocolVersionHeaderValues.Enqueue(context.Request.Headers["mcp-protocol-version"]); } await next(context); diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index 9765ed928..9a54ed71d 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -170,7 +170,7 @@ private static void ConfigureTools(McpServerOptions options, string? cliArg) { if (request.Params?.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message)) { - throw new McpException("Missing required argument 'message'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'message'", McpErrorCode.InvalidParams); } return new CallToolResult { @@ -190,7 +190,7 @@ private static void ConfigureTools(McpServerOptions options, string? cliArg) !request.Params.Arguments.TryGetValue("prompt", out var prompt) || !request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens)) { - throw new McpException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams); } var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "sampleLLM", Convert.ToInt32(maxTokens.GetRawText())), cancellationToken); @@ -209,7 +209,7 @@ private static void ConfigureTools(McpServerOptions options, string? cliArg) } else { - throw new McpException($"Unknown tool: {request.Params?.Name}", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unknown tool: {request.Params?.Name}", McpErrorCode.InvalidParams); } }; } @@ -287,7 +287,7 @@ private static void ConfigurePrompts(McpServerOptions options) } else { - throw new McpException($"Unknown prompt: {request.Params?.Name}", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unknown prompt: {request.Params?.Name}", McpErrorCode.InvalidParams); } return new GetPromptResult @@ -305,7 +305,7 @@ private static void ConfigureLogging(McpServerOptions options) { if (request.Params?.Level is null) { - throw new McpException("Missing required argument 'level'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'level'", McpErrorCode.InvalidParams); } _minimumLoggingLevel = request.Params.Level; @@ -387,7 +387,7 @@ private static void ConfigureResources(McpServerOptions options) } catch (Exception e) { - throw new McpException($"Invalid cursor: '{request.Params.Cursor}'", e, McpErrorCode.InvalidParams); + throw new McpProtocolException($"Invalid cursor: '{request.Params.Cursor}'", e, McpErrorCode.InvalidParams); } } @@ -409,7 +409,7 @@ private static void ConfigureResources(McpServerOptions options) { if (request.Params?.Uri is null) { - throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams); } if (request.Params.Uri.StartsWith("test://dynamic/resource/")) @@ -417,7 +417,7 @@ private static void ConfigureResources(McpServerOptions options) var id = request.Params.Uri.Split('/').LastOrDefault(); if (string.IsNullOrEmpty(id)) { - throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); } return new ReadResourceResult @@ -434,7 +434,7 @@ private static void ConfigureResources(McpServerOptions options) } ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) - ?? throw new McpException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); + ?? throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); return new ReadResourceResult { @@ -446,12 +446,12 @@ private static void ConfigureResources(McpServerOptions options) { if (request?.Params?.Uri is null) { - throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams); } if (!request.Params.Uri.StartsWith("test://static/resource/") && !request.Params.Uri.StartsWith("test://dynamic/resource/")) { - throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); } _subscribedResources.TryAdd(request.Params.Uri, true); @@ -463,12 +463,12 @@ private static void ConfigureResources(McpServerOptions options) { if (request?.Params?.Uri is null) { - throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams); } if (!request.Params.Uri.StartsWith("test://static/resource/") && !request.Params.Uri.StartsWith("test://dynamic/resource/")) { - throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); } _subscribedResources.TryRemove(request.Params.Uri, out _); @@ -509,7 +509,7 @@ private static void ConfigureCompletions(McpServerOptions options) return new CompleteResult { Completion = new() { Values = values, HasMore = false, Total = values.Length } }; default: - throw new McpException($"Unknown reference type: '{request.Params?.Ref.Type}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unknown reference type: '{request.Params?.Ref.Type}'", McpErrorCode.InvalidParams); } }; } diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index c2898c542..e2eb3435f 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -158,13 +158,13 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st { if (request.Params is null) { - throw new McpException("Missing required parameter 'name'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required parameter 'name'", McpErrorCode.InvalidParams); } if (request.Params.Name == "echo") { if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message)) { - throw new McpException("Missing required argument 'message'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'message'", McpErrorCode.InvalidParams); } return new CallToolResult { @@ -184,7 +184,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st !request.Params.Arguments.TryGetValue("prompt", out var prompt) || !request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens)) { - throw new McpException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams); } var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "sampleLLM", Convert.ToInt32(maxTokens.ToString())), cancellationToken); @@ -196,7 +196,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } else { - throw new McpException($"Unknown tool: '{request.Params.Name}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unknown tool: '{request.Params.Name}'", McpErrorCode.InvalidParams); } }, ListResourceTemplatesHandler = async (request, cancellationToken) => @@ -226,7 +226,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } catch (Exception e) { - throw new McpException($"Invalid cursor: '{requestParams.Cursor}'", e, McpErrorCode.InvalidParams); + throw new McpProtocolException($"Invalid cursor: '{requestParams.Cursor}'", e, McpErrorCode.InvalidParams); } } @@ -248,7 +248,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st { if (request.Params?.Uri is null) { - throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams); } if (request.Params.Uri.StartsWith("test://dynamic/resource/")) @@ -256,7 +256,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st var id = request.Params.Uri.Split('/').LastOrDefault(); if (string.IsNullOrEmpty(id)) { - throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams); } return new ReadResourceResult @@ -273,7 +273,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ?? - throw new McpException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); return new ReadResourceResult { @@ -317,7 +317,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st { if (request.Params is null) { - throw new McpException("Missing required parameter 'name'", McpErrorCode.InvalidParams); + throw new McpProtocolException("Missing required parameter 'name'", McpErrorCode.InvalidParams); } List messages = new(); if (request.Params.Name == "simple_prompt") @@ -354,7 +354,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } else { - throw new McpException($"Unknown prompt: {request.Params.Name}", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unknown prompt: {request.Params.Name}", McpErrorCode.InvalidParams); } return new GetPromptResult diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 20c6f374b..16fad124a 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -193,7 +193,7 @@ public async Task GetPrompt_NonExistent_ThrowsException(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsFilterTests.cs index 00e67c247..32b588d09 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsFilterTests.cs @@ -58,7 +58,18 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer var logger = GetLogger(request.Services, "CallToolFilter"); var primitiveId = request.MatchedPrimitive?.Id ?? "unknown"; logger.LogInformation($"CallToolFilter executed for tool: {primitiveId}"); - return await next(request, cancellationToken); + try + { + return await next(request, cancellationToken); + } + catch (Exception ex) + { + return new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = $"Error from filter: {ex.Message}" }], + IsError = true + }; + } }) .AddListPromptsFilter((next) => async (request, cancellationToken) => { @@ -162,6 +173,20 @@ public async Task AddCallToolFilter_Logs_When_CallTool_Called() Assert.Equal("CallToolFilter", logMessage.Category); } + [Fact] + public async Task AddCallToolFilter_Catches_Exception_From_Tool() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync("throwing_tool_method", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.NotNull(result.Content); + var textContent = Assert.Single(result.Content); + var textBlock = Assert.IsType(textContent); + Assert.Equal("Error from filter: This tool always throws an exception", textBlock.Text); + } + [Fact] public async Task AddListPromptsFilter_Logs_When_ListPrompts_Called() { @@ -286,6 +311,12 @@ public static string TestToolMethod() { return "test result"; } + + [McpServerTool] + public static string ThrowingToolMethod() + { + throw new InvalidOperationException("This tool always throws an exception"); + } } [McpServerPromptType] diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 2df57dbf3..8fdeacb9b 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -62,7 +62,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; default: - throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); } }) .WithGetPromptHandler(async (request, cancellationToken) => @@ -78,7 +78,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; default: - throw new McpException($"Unknown prompt '{request.Params?.Name}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unknown prompt '{request.Params?.Name}'", McpErrorCode.InvalidParams); } }) .WithPrompts(); @@ -190,7 +190,7 @@ public async Task Throws_When_Prompt_Fails() { await using McpClient client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync(async () => await client.GetPromptAsync( + await Assert.ThrowsAsync(async () => await client.GetPromptAsync( nameof(SimplePrompts.ThrowsException), cancellationToken: TestContext.Current.CancellationToken)); } @@ -200,7 +200,7 @@ public async Task Throws_Exception_On_Unknown_Prompt() { await using McpClient client = await CreateMcpClientForServer(); - var e = await Assert.ThrowsAsync(async () => await client.GetPromptAsync( + var e = await Assert.ThrowsAsync(async () => await client.GetPromptAsync( "NotRegisteredPrompt", cancellationToken: TestContext.Current.CancellationToken)); @@ -212,7 +212,7 @@ public async Task Throws_Exception_Missing_Parameter() { await using McpClient client = await CreateMcpClientForServer(); - var e = await Assert.ThrowsAsync(async () => await client.GetPromptAsync( + var e = await Assert.ThrowsAsync(async () => await client.GetPromptAsync( "returns_chat_messages", cancellationToken: TestContext.Current.CancellationToken)); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 3a8a63e8f..7d037fb2d 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -62,7 +62,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; default: - throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); } }) .WithListResourceTemplatesHandler(async (request, cancellationToken) => @@ -91,7 +91,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }], }; default: - throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); } }) .WithReadResourceHandler(async (request, cancellationToken) => @@ -109,7 +109,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; } - throw new McpException($"Resource not found: {request.Params?.Uri}"); + throw new McpProtocolException($"Resource not found: {request.Params?.Uri}"); }) .WithResources(); } @@ -235,7 +235,7 @@ public async Task Throws_When_Resource_Fails() { await using McpClient client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( + await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( $"resource://mcp/{nameof(SimpleResources.ThrowsException)}", cancellationToken: TestContext.Current.CancellationToken)); } @@ -245,7 +245,7 @@ public async Task Throws_Exception_On_Unknown_Resource() { await using McpClient client = await CreateMcpClientForServer(); - var e = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( + var e = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( "test:///NotRegisteredResource", cancellationToken: TestContext.Current.CancellationToken)); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index ba47d1b8d..88c74e9b1 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -97,7 +97,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; default: - throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams); } }) .WithCallToolHandler(async (request, cancellationToken) => @@ -113,7 +113,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; default: - throw new McpException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams); + throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams); } }) .WithTools(serializerOptions: BuilderToolsJsonContext.Default.Options); @@ -395,7 +395,7 @@ public async Task Throws_Exception_On_Unknown_Tool() { await using McpClient client = await CreateMcpClientForServer(); - var e = await Assert.ThrowsAsync(async () => await client.CallToolAsync( + var e = await Assert.ThrowsAsync(async () => await client.CallToolAsync( "NotRegisteredTool", cancellationToken: TestContext.Current.CancellationToken)); diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index 8996b9962..b0e7b6d07 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -87,7 +87,7 @@ public async Task Session_FailedToolCall() await RunConnected(async (client, server) => { await client.CallToolAsync("Throw", cancellationToken: TestContext.Current.CancellationToken); - await Assert.ThrowsAsync(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken)); }, new List()); } diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 3c7d631ad..944eec4d5 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -53,7 +53,6 @@ - diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index 47da166ca..55e32f4ae 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -248,7 +248,7 @@ public async Task Elicit_Typed_With_Unsupported_Property_Type_Throws() }, }); - var ex = await Assert.ThrowsAsync(async() => + var ex = await Assert.ThrowsAsync(async() => await client.CallToolAsync("TestElicitationUnsupportedType", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains(typeof(UnsupportedForm.Nested).FullName!, ex.Message); @@ -270,7 +270,7 @@ public async Task Elicit_Typed_With_Nullable_Property_Type_Throws() } }); - var ex = await Assert.ThrowsAsync(async () => + var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsync("TestElicitationNullablePropertyForm", cancellationToken: TestContext.Current.CancellationToken)); } @@ -290,7 +290,7 @@ public async Task Elicit_Typed_With_NonObject_Generic_Type_Throws() } }); - var ex = await Assert.ThrowsAsync(async () => + var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsync("TestElicitationNonObjectGenericType", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains(typeof(string).FullName!, ex.Message); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 40461d415..4352570e7 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -532,6 +532,110 @@ public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_A await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured"); } + [Fact] + public async Task Can_Handle_Call_Tool_Requests_With_McpException() + { + const string errorMessage = "Tool execution failed with detailed error"; + await Can_Handle_Requests( + new ServerCapabilities + { + Tools = new() + }, + method: RequestMethods.ToolsCall, + configureOptions: options => + { + options.Handlers.CallToolHandler = async (request, ct) => + { + throw new McpException(errorMessage); + }; + options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException(); + }, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotEmpty(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Contains(errorMessage, textContent.Text); + }); + } + + [Fact] + public async Task Can_Handle_Call_Tool_Requests_With_Plain_Exception() + { + await Can_Handle_Requests( + new ServerCapabilities + { + Tools = new() + }, + method: RequestMethods.ToolsCall, + configureOptions: options => + { + options.Handlers.CallToolHandler = async (request, ct) => + { + throw new InvalidOperationException("This sensitive message should not be exposed"); + }; + options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException(); + }, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotEmpty(result.Content); + var textContent = Assert.IsType(result.Content[0]); + // Should be a generic error message, not the actual exception message + Assert.DoesNotContain("sensitive", textContent.Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("An error occurred", textContent.Text); + }); + } + + [Fact] + public async Task Can_Handle_Call_Tool_Requests_With_McpProtocolException() + { + const string errorMessage = "Invalid tool parameters"; + const McpErrorCode errorCode = McpErrorCode.InvalidParams; + + await using var transport = new TestServerTransport(); + var options = CreateOptions(new ServerCapabilities { Tools = new() }); + options.Handlers.CallToolHandler = async (request, ct) => + { + throw new McpProtocolException(errorMessage, errorCode); + }; + options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException(); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + + var runTask = server.RunAsync(TestContext.Current.CancellationToken); + + var receivedMessage = new TaskCompletionSource(); + + transport.OnMessageSent = (message) => + { + if (message is JsonRpcError error && error.Id.ToString() == "55") + receivedMessage.SetResult(error); + }; + + await transport.SendMessageAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Id = new RequestId(55) + }, + TestContext.Current.CancellationToken + ); + + var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.NotNull(error); + Assert.NotNull(error.Error); + Assert.Equal((int)errorCode, error.Error.Code); + Assert.Equal(errorMessage, error.Error.Message); + + await transport.DisposeAsync(); + await runTask; + } + private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action? configureOptions, Action assertResult) { await using var transport = new TestServerTransport(); From b8e3d9f03585e490035145ca2fe5a27098626030 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:44:17 +0000 Subject: [PATCH 6/6] Merge main to resolve conflicts with latest changes Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- Directory.Packages.props | 18 +++++----- .../samples/server/Tools/InteractiveTools.cs | 2 +- .../AspNetCoreMcpPerSessionTools/Program.cs | 2 +- .../CancellableStreamReader.cs | 4 +-- .../McpAuthenticationOptions.cs | 6 ++-- .../AuthorizationFilterSetup.cs | 14 ++++---- .../ModelContextProtocol.AspNetCore.csproj | 2 +- .../AIContentExtensions.cs | 5 +-- .../Authentication/ClientOAuthProvider.cs | 36 +++++++++++-------- .../Client/McpClientOptions.cs | 6 ++-- .../Client/StdioClientSessionTransport.cs | 20 ++++------- .../Client/StdioClientTransport.cs | 14 ++++++-- src/ModelContextProtocol.Core/Diagnostics.cs | 4 +-- .../McpSessionHandler.cs | 6 ++-- .../ModelContextProtocol.Core.csproj | 12 ++++--- .../NotificationHandlers.cs | 10 ++---- .../Protocol/ProgressToken.cs | 19 +++++----- .../Protocol/RequestId.cs | 19 +++++----- .../Server/AIFunctionMcpServerPrompt.cs | 2 +- .../Server/AIFunctionMcpServerResource.cs | 2 +- .../Server/AIFunctionMcpServerTool.cs | 2 +- .../Server/DestinationBoundMcpServer.cs | 14 +++++--- .../Server/McpServer.Methods.cs | 32 +++++------------ .../Server/McpServerFilters.cs | 22 ++++++------ .../Server/McpServerImpl.cs | 9 ++--- .../Server/McpServerOptions.cs | 6 ++-- .../Server/McpServerPromptCreateOptions.cs | 2 +- .../Server/McpServerResourceCreateOptions.cs | 2 +- .../Server/McpServerToolCreateOptions.cs | 2 +- .../Server/RequestContext.cs | 12 ++----- src/ModelContextProtocol.Core/UriTemplate.cs | 8 ++--- .../ModelContextProtocol.csproj | 2 +- .../AuthorizeAttributeTests.cs | 2 +- .../MapMcpTests.cs | 2 +- .../StreamableHttpServerConformanceTests.cs | 2 +- .../AuthorizationServerMetadata.cs | 2 +- .../Program.cs | 6 ++-- .../DiagnosticTests.cs | 2 +- .../Protocol/IconTests.cs | 4 +-- .../Protocol/ImplementationTests.cs | 4 +-- .../Protocol/PromptTests.cs | 2 +- .../Protocol/ResourceTests.cs | 4 +-- .../Protocol/ToolTests.cs | 2 +- .../Server/McpServerPromptTests.cs | 2 +- .../Server/McpServerResourceTests.cs | 6 ++-- .../Server/McpServerToolTests.cs | 6 ++-- 46 files changed, 168 insertions(+), 194 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2d8f454e8..adb9b43d1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,9 +1,9 @@ true - 9.0.5 - 10.0.0-preview.4.25258.110 - 9.9.1 + 9.0.10 + 10.0.0-rc.2.25502.107 + 9.10.0 @@ -28,19 +28,17 @@ - + - - + - @@ -56,19 +54,19 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + diff --git a/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs b/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs index b907a805d..1528fa5a6 100644 --- a/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs +++ b/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs @@ -73,7 +73,7 @@ CancellationToken token string? playerName = nameResponse.Content?["Name"].GetString(); // Generate a random number between 1 and 10 - Random random = new Random(); + Random random = new(); int targetNumber = random.Next(1, 11); // 1 to 10 inclusive int attempts = 0; diff --git a/samples/AspNetCoreMcpPerSessionTools/Program.cs b/samples/AspNetCoreMcpPerSessionTools/Program.cs index 3484978ec..b9174cd7a 100644 --- a/samples/AspNetCoreMcpPerSessionTools/Program.cs +++ b/samples/AspNetCoreMcpPerSessionTools/Program.cs @@ -24,7 +24,7 @@ { mcpOptions.Capabilities = new(); mcpOptions.Capabilities.Tools = new(); - var toolCollection = mcpOptions.ToolCollection = new(); + var toolCollection = mcpOptions.ToolCollection = []; foreach (var tool in tools) { diff --git a/src/Common/CancellableStreamReader/CancellableStreamReader.cs b/src/Common/CancellableStreamReader/CancellableStreamReader.cs index f6df72d22..9bc9ac6ac 100644 --- a/src/Common/CancellableStreamReader/CancellableStreamReader.cs +++ b/src/Common/CancellableStreamReader/CancellableStreamReader.cs @@ -350,7 +350,7 @@ public override string ReadToEnd() CheckAsyncTaskInProgress(); // Call ReadBuffer, then pull data out of charBuffer. - StringBuilder sb = new StringBuilder(_charLen - _charPos); + StringBuilder sb = new(_charLen - _charPos); do { sb.Append(_charBuffer, _charPos, _charLen - _charPos); @@ -953,7 +953,7 @@ public virtual Task ReadToEndAsync(CancellationToken cancellationToken) private async Task ReadToEndAsyncInternal(CancellationToken cancellationToken) { // Call ReadBuffer, then pull data out of charBuffer. - StringBuilder sb = new StringBuilder(_charLen - _charPos); + StringBuilder sb = new(_charLen - _charPos); do { int tmpCharPos = _charPos; diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs index ecb6c6c82..c7280e455 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs @@ -25,9 +25,9 @@ public McpAuthenticationOptions() /// Gets or sets the events used to handle authentication events. /// public new McpAuthenticationEvents Events - { - get { return (McpAuthenticationEvents)base.Events!; } - set { base.Events = value; } + { + get => (McpAuthenticationEvents)base.Events!; + set => base.Events = value; } /// diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs index dce594e2e..ae5e42dd8 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -55,7 +55,7 @@ await FilterAuthorizedItemsAsync( }); } - private void CheckListToolsFilter(McpServerOptions options) + private static void CheckListToolsFilter(McpServerOptions options) { options.Filters.ListToolsFilters.Add(next => async (context, cancellationToken) => { @@ -87,7 +87,7 @@ private void ConfigureCallToolFilter(McpServerOptions options) }); } - private void CheckCallToolFilter(McpServerOptions options) + private static void CheckCallToolFilter(McpServerOptions options) { options.Filters.CallToolFilters.Add(next => async (context, cancellationToken) => { @@ -115,7 +115,7 @@ await FilterAuthorizedItemsAsync( }); } - private void CheckListResourcesFilter(McpServerOptions options) + private static void CheckListResourcesFilter(McpServerOptions options) { options.Filters.ListResourcesFilters.Add(next => async (context, cancellationToken) => { @@ -145,7 +145,7 @@ await FilterAuthorizedItemsAsync( }); } - private void CheckListResourceTemplatesFilter(McpServerOptions options) + private static void CheckListResourceTemplatesFilter(McpServerOptions options) { options.Filters.ListResourceTemplatesFilters.Add(next => async (context, cancellationToken) => { @@ -177,7 +177,7 @@ private void ConfigureReadResourceFilter(McpServerOptions options) }); } - private void CheckReadResourceFilter(McpServerOptions options) + private static void CheckReadResourceFilter(McpServerOptions options) { options.Filters.ReadResourceFilters.Add(next => async (context, cancellationToken) => { @@ -205,7 +205,7 @@ await FilterAuthorizedItemsAsync( }); } - private void CheckListPromptsFilter(McpServerOptions options) + private static void CheckListPromptsFilter(McpServerOptions options) { options.Filters.ListPromptsFilters.Add(next => async (context, cancellationToken) => { @@ -237,7 +237,7 @@ private void ConfigureGetPromptFilter(McpServerOptions options) }); } - private void CheckGetPromptFilter(McpServerOptions options) + private static void CheckGetPromptFilter(McpServerOptions options) { options.Filters.GetPromptFilters.Add(next => async (context, cancellationToken) => { diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 7968e23f8..a957bd969 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0 + net10.0;net9.0;net8.0 enable enable true diff --git a/src/ModelContextProtocol.Core/AIContentExtensions.cs b/src/ModelContextProtocol.Core/AIContentExtensions.cs index 6b6a9c780..b792bc1af 100644 --- a/src/ModelContextProtocol.Core/AIContentExtensions.cs +++ b/src/ModelContextProtocol.Core/AIContentExtensions.cs @@ -128,10 +128,7 @@ public static IList ToPromptMessages(this ChatMessage chatMessage _ => null, }; - if (ac is not null) - { - ac.RawRepresentation = content; - } + ac?.RawRepresentation = content; return ac; } diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index b72f775c4..468728982 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +#if NET9_0_OR_GREATER +using System.Buffers.Text; +#endif using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Security.Cryptography; @@ -238,14 +241,17 @@ private async Task PerformOAuthAuthorizationAsync( LogOAuthAuthorizationCompleted(); } + private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"]; + private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) { - if (!authServerUri.OriginalString.EndsWith("/")) + if (authServerUri.OriginalString.Length == 0 || + authServerUri.OriginalString[authServerUri.OriginalString.Length - 1] != '/') { - authServerUri = new Uri(authServerUri.OriginalString + "/"); + authServerUri = new Uri($"{authServerUri.OriginalString}/"); } - foreach (var path in new[] { ".well-known/openid-configuration", ".well-known/oauth-authorization-server" }) + foreach (var path in s_wellKnownPaths) { try { @@ -540,11 +546,7 @@ private static string NormalizeUri(Uri uri) Port = -1 // Always remove port }; - if (builder.Path == "/") - { - builder.Path = string.Empty; - } - else if (builder.Path.Length > 1 && builder.Path.EndsWith("/")) + if (builder.Path.Length > 0 && builder.Path[builder.Path.Length - 1] == '/') { builder.Path = builder.Path.TrimEnd('/'); } @@ -633,18 +635,18 @@ private async Task ExtractProtectedResourceMetadata(H continue; } - string key = trimmedPart.Substring(0, equalsIndex).Trim(); + ReadOnlySpan key = trimmedPart.AsSpan().Slice(0, equalsIndex).Trim(); - if (string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) + if (key.Equals(parameterName, StringComparison.OrdinalIgnoreCase)) { - string value = trimmedPart.Substring(equalsIndex + 1).Trim(); + ReadOnlySpan value = trimmedPart.AsSpan(equalsIndex + 1).Trim(); - if (value.StartsWith("\"") && value.EndsWith("\"")) + if (value.Length > 0 && value[0] == '"' && value[value.Length - 1] == '"') { - value = value.Substring(1, value.Length - 2); + value = value.Slice(1, value.Length - 2); } - return value; + return value.ToString(); } } @@ -664,12 +666,18 @@ private static string GenerateCodeVerifier() private static string GenerateCodeChallenge(string codeVerifier) { +#if NET9_0_OR_GREATER + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier), hash); + return Base64Url.EncodeToString(hash); +#else using var sha256 = SHA256.Create(); var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); return Convert.ToBase64String(challengeBytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); +#endif } private string GetClientIdOrThrow() => _clientId ?? throw new InvalidOperationException("Client ID is not available. This may indicate an issue with dynamic client registration."); diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index ff71f5899..622539386 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -11,8 +11,6 @@ namespace ModelContextProtocol.Client; /// public sealed class McpClientOptions { - private McpClientHandlers? _handlers; - /// /// Gets or sets information about this client implementation, including its name and version. /// @@ -71,11 +69,11 @@ public sealed class McpClientOptions /// public McpClientHandlers Handlers { - get => _handlers ??= new(); + get => field ??= new(); set { Throw.IfNull(value); - _handlers = value; + field = value; } } } diff --git a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs index 2ce32cb72..a9c228d43 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs @@ -5,21 +5,15 @@ namespace ModelContextProtocol.Client; /// Provides the client side of a stdio-based session transport. -internal sealed class StdioClientSessionTransport : StreamClientSessionTransport +internal sealed class StdioClientSessionTransport( + StdioClientTransportOptions options, Process process, string endpointName, Queue stderrRollingLog, ILoggerFactory? loggerFactory) : + StreamClientSessionTransport(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory) { - private readonly StdioClientTransportOptions _options; - private readonly Process _process; - private readonly Queue _stderrRollingLog; + private readonly StdioClientTransportOptions _options = options; + private readonly Process _process = process; + private readonly Queue _stderrRollingLog = stderrRollingLog; private int _cleanedUp = 0; - public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue stderrRollingLog, ILoggerFactory? loggerFactory) - : base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory) - { - _process = process; - _options = options; - _stderrRollingLog = stderrRollingLog; - } - /// public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { @@ -56,7 +50,7 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell // Now terminate the server process. try { - StdioClientTransport.DisposeProcess(_process, processRunning: true, _options.ShutdownTimeout, Name); + StdioClientTransport.DisposeProcess(_process, processRunning: true, shutdownTimeout: _options.ShutdownTimeout); } catch (Exception ex) { diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs index 3ec0d2880..f3e624159 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs @@ -39,7 +39,7 @@ public StdioClientTransport(StdioClientTransportOptions options, ILoggerFactory? _options = options; _loggerFactory = loggerFactory; - Name = options.Name ?? $"stdio-{Regex.Replace(Path.GetFileName(options.Command), @"[\s\.]+", "-")}"; + Name = options.Name ?? $"stdio-{WhitespaceAndPeriods().Replace(Path.GetFileName(options.Command), "-")}"; } /// @@ -189,7 +189,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = try { - DisposeProcess(process, processStarted, _options.ShutdownTimeout, endpointName); + DisposeProcess(process, processStarted, _options.ShutdownTimeout); } catch (Exception ex2) { @@ -201,7 +201,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = } internal static void DisposeProcess( - Process? process, bool processRunning, TimeSpan shutdownTimeout, string endpointName) + Process? process, bool processRunning, TimeSpan shutdownTimeout) { if (process is not null) { @@ -279,4 +279,12 @@ private static string EscapeArgumentString(string argument) => [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} shutdown failed.")] private static partial void LogTransportShutdownFailed(ILogger logger, string endpointName, Exception exception); + +#if NET + [GeneratedRegex(@"[\s\.]+")] + private static partial Regex WhitespaceAndPeriods(); +#else + private static Regex WhitespaceAndPeriods() => s_whitespaceAndPeriods; + private static readonly Regex s_whitespaceAndPeriods = new(@"[\s\.]+", RegexOptions.Compiled); +#endif } diff --git a/src/ModelContextProtocol.Core/Diagnostics.cs b/src/ModelContextProtocol.Core/Diagnostics.cs index 308d07c5a..284d9088e 100644 --- a/src/ModelContextProtocol.Core/Diagnostics.cs +++ b/src/ModelContextProtocol.Core/Diagnostics.cs @@ -93,9 +93,9 @@ private static void InjectContext(object? message, string key, string value) { if (jsonObject["_meta"] is not JsonObject meta) { - meta = new JsonObject(); - jsonObject["_meta"] = meta; + jsonObject["_meta"] = meta = []; } + meta[key] = value; } } diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index e47354742..d407f4265 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -405,7 +405,7 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null; using Activity? activity = Diagnostics.ShouldInstrumentMessage(request) ? - Diagnostics.ActivitySource.StartActivity(CreateActivityName(method), ActivityKind.Client) : + Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) : null; // Set request ID @@ -502,7 +502,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null; using Activity? activity = Diagnostics.ShouldInstrumentMessage(message) ? - Diagnostics.ActivitySource.StartActivity(CreateActivityName(method), ActivityKind.Client) : + Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) : null; TagList tags = default; @@ -568,7 +568,7 @@ private Task SendToRelatedTransportAsync(JsonRpcMessage message, CancellationTok } } - private string CreateActivityName(string method) => method; + private static string CreateActivityName(string method) => method; private static string GetMethodName(JsonRpcMessage message) => message switch diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 07a5ec1b0..d39c008eb 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0;netstandard2.0 + net10.0;net9.0;net8.0;netstandard2.0 true true ModelContextProtocol.Core @@ -27,16 +27,20 @@ - - + + + + + + + - diff --git a/src/ModelContextProtocol.Core/NotificationHandlers.cs b/src/ModelContextProtocol.Core/NotificationHandlers.cs index 4b9520730..da09c75e8 100644 --- a/src/ModelContextProtocol.Core/NotificationHandlers.cs +++ b/src/ModelContextProtocol.Core/NotificationHandlers.cs @@ -240,14 +240,8 @@ public async ValueTask DisposeAsync() // to point past this one. Importantly, we do not modify this node's Next or Prev. // We want to ensure that an enumeration through all of the registrations can still // progress through this one. - if (Prev is not null) - { - Prev.Next = Next; - } - if (Next is not null) - { - Next.Prev = Prev; - } + Prev?.Next = Next; + Next?.Prev = Prev; // Decrement the ref count. In the common case, there's no in-flight invocation for // this handler. However, in the uncommon case that there is, we need to wait for diff --git a/src/ModelContextProtocol.Core/Protocol/ProgressToken.cs b/src/ModelContextProtocol.Core/Protocol/ProgressToken.cs index f980bdf6f..bdb35f85b 100644 --- a/src/ModelContextProtocol.Core/Protocol/ProgressToken.cs +++ b/src/ModelContextProtocol.Core/Protocol/ProgressToken.cs @@ -11,15 +11,12 @@ namespace ModelContextProtocol.Protocol; [JsonConverter(typeof(Converter))] public readonly struct ProgressToken : IEquatable { - /// The token, either a string or a boxed long or null. - private readonly object? _token; - /// Initializes a new instance of the with a specified value. /// The required ID value. public ProgressToken(string value) { Throw.IfNull(value); - _token = value; + Token = value; } /// Initializes a new instance of the with a specified value. @@ -27,27 +24,27 @@ public ProgressToken(string value) public ProgressToken(long value) { // Box the long. Progress tokens are almost always strings in practice, so this should be rare. - _token = value; + Token = value; } /// Gets the underlying object for this token. /// This will either be a , a boxed , or . - public object? Token => _token; + public object? Token { get; } /// public override string? ToString() => - _token is string stringValue ? stringValue : - _token is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) : + Token is string stringValue ? stringValue : + Token is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) : null; /// - public bool Equals(ProgressToken other) => Equals(_token, other._token); + public bool Equals(ProgressToken other) => Equals(Token, other.Token); /// public override bool Equals(object? obj) => obj is ProgressToken other && Equals(other); /// - public override int GetHashCode() => _token?.GetHashCode() ?? 0; + public override int GetHashCode() => Token?.GetHashCode() ?? 0; /// public static bool operator ==(ProgressToken left, ProgressToken right) => left.Equals(right); @@ -77,7 +74,7 @@ public override void Write(Utf8JsonWriter writer, ProgressToken value, JsonSeria { Throw.IfNull(writer); - switch (value._token) + switch (value.Token) { case string str: writer.WriteStringValue(str); diff --git a/src/ModelContextProtocol.Core/Protocol/RequestId.cs b/src/ModelContextProtocol.Core/Protocol/RequestId.cs index 8d445deb8..39abe5abc 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestId.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestId.cs @@ -11,15 +11,12 @@ namespace ModelContextProtocol.Protocol; [JsonConverter(typeof(Converter))] public readonly struct RequestId : IEquatable { - /// The id, either a string or a boxed long or null. - private readonly object? _id; - /// Initializes a new instance of the with a specified value. /// The required ID value. public RequestId(string value) { Throw.IfNull(value); - _id = value; + Id = value; } /// Initializes a new instance of the with a specified value. @@ -27,27 +24,27 @@ public RequestId(string value) public RequestId(long value) { // Box the long. Request IDs are almost always strings in practice, so this should be rare. - _id = value; + Id = value; } /// Gets the underlying object for this id. /// This will either be a , a boxed , or . - public object? Id => _id; + public object? Id { get; } /// public override string ToString() => - _id is string stringValue ? stringValue : - _id is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) : + Id is string stringValue ? stringValue : + Id is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) : string.Empty; /// - public bool Equals(RequestId other) => Equals(_id, other._id); + public bool Equals(RequestId other) => Equals(Id, other.Id); /// public override bool Equals(object? obj) => obj is RequestId other && Equals(other); /// - public override int GetHashCode() => _id?.GetHashCode() ?? 0; + public override int GetHashCode() => Id?.GetHashCode() ?? 0; /// public static bool operator ==(RequestId left, RequestId right) => left.Equals(right); @@ -77,7 +74,7 @@ public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerialize { Throw.IfNull(writer); - switch (value._id) + switch (value.Id) { case string str: writer.WriteStringValue(str); diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index dadd876bb..720c37521 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -59,7 +59,7 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt return Create( AIFunctionFactory.Create(method, args => { - Debug.Assert(args.Services is RequestServiceProvider, $"The service provider should be a {nameof(RequestServiceProvider)} for this method to work correctly."); + Debug.Assert(args.Services is RequestServiceProvider, $"The service provider should be a {nameof(RequestServiceProvider<>)} for this method to work correctly."); return createTargetFunc(((RequestServiceProvider)args.Services!).Request); }, CreateAIFunctionFactoryOptions(method, options)), options); diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 350f0d9b2..5746a7dff 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -67,7 +67,7 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource return Create( AIFunctionFactory.Create(method, args => { - Debug.Assert(args.Services is RequestServiceProvider, $"The service provider should be a {nameof(RequestServiceProvider)} for this method to work correctly."); + Debug.Assert(args.Services is RequestServiceProvider, $"The service provider should be a {nameof(RequestServiceProvider<>)} for this method to work correctly."); return createTargetFunc(((RequestServiceProvider)args.Services!).Request); }, CreateAIFunctionFactoryOptions(method, options)), options); diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 91fbb3d6a..aa50046f9 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -63,7 +63,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool return Create( AIFunctionFactory.Create(method, args => { - Debug.Assert(args.Services is RequestServiceProvider, $"The service provider should be a {nameof(RequestServiceProvider)} for this method to work correctly."); + Debug.Assert(args.Services is RequestServiceProvider, $"The service provider should be a {nameof(RequestServiceProvider<>)} for this method to work correctly."); return createTargetFunc(((RequestServiceProvider)args.Services!).Request); }, CreateAIFunctionFactoryOptions(method, options)), options); diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index bbbc45dcc..784e0f9a6 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -27,8 +27,11 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken throw new ArgumentException("Only transports can provide a JsonRpcMessageContext."); } - message.Context = new JsonRpcMessageContext(); - message.Context.RelatedTransport = transport; + message.Context = new() + { + RelatedTransport = transport + }; + return server.SendMessageAsync(message, cancellationToken); } @@ -39,8 +42,11 @@ public override Task SendRequestAsync(JsonRpcRequest request, C throw new ArgumentException("Only transports can provide a JsonRpcMessageContext."); } - request.Context = new JsonRpcMessageContext(); - request.Context.RelatedTransport = transport; + request.Context = new() + { + RelatedTransport = transport + }; + return server.SendRequestAsync(request, cancellationToken); } } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 0d255c5b0..a24918bd9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -341,13 +341,9 @@ private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSche throw new McpProtocolException(error); } - var primitiveSchemaDefinition = - jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); - - if (primitiveSchemaDefinition is null) + return + jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition) ?? throw new McpProtocolException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); - - return primitiveSchemaDefinition; } /// @@ -452,11 +448,9 @@ private void ThrowIfElicitationUnsupported() } /// Provides an implementation that's implemented via client sampling. - private sealed class SamplingChatClient : IChatClient + private sealed class SamplingChatClient(McpServer server) : IChatClient { - private readonly McpServer _server; - - public SamplingChatClient(McpServer server) => _server = server; + private readonly McpServer _server = server; /// public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => @@ -493,11 +487,9 @@ void IDisposable.Dispose() { } // nop /// Provides an implementation for creating loggers /// that send logging message notifications to the client for logged messages. /// - private sealed class ClientLoggerProvider : ILoggerProvider + private sealed class ClientLoggerProvider(McpServer server) : ILoggerProvider { - private readonly McpServer _server; - - public ClientLoggerProvider(McpServer server) => _server = server; + private readonly McpServer _server = server; /// public ILogger CreateLogger(string categoryName) @@ -510,16 +502,10 @@ public ILogger CreateLogger(string categoryName) /// void IDisposable.Dispose() { } - private sealed class ClientLogger : ILogger + private sealed class ClientLogger(McpServer server, string categoryName) : ILogger { - private readonly McpServer _server; - private readonly string _categoryName; - - public ClientLogger(McpServer server, string categoryName) - { - _server = server; - _categoryName = categoryName; - } + private readonly McpServer _server = server; + private readonly string _categoryName = categoryName; /// public IDisposable? BeginScope(TState state) where TState : notnull => diff --git a/src/ModelContextProtocol.Core/Server/McpServerFilters.cs b/src/ModelContextProtocol.Core/Server/McpServerFilters.cs index e38421bc1..d68b349d2 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerFilters.cs @@ -26,7 +26,7 @@ public sealed class McpServerFilters /// Tools from both sources will be combined when returning results to clients. /// /// - public List> ListToolsFilters { get; } = new(); + public List> ListToolsFilters { get; } = []; /// /// Gets the filters for the call tool handler pipeline. @@ -36,7 +36,7 @@ public sealed class McpServerFilters /// The filters can modify, log, or perform additional operations on requests and responses for /// requests. The handler should implement logic to execute the requested tool and return appropriate results. /// - public List> CallToolFilters { get; } = new(); + public List> CallToolFilters { get; } = []; /// /// Gets the filters for the list prompts handler pipeline. @@ -53,7 +53,7 @@ public sealed class McpServerFilters /// Prompts from both sources will be combined when returning results to clients. /// /// - public List> ListPromptsFilters { get; } = new(); + public List> ListPromptsFilters { get; } = []; /// /// Gets the filters for the get prompt handler pipeline. @@ -63,7 +63,7 @@ public sealed class McpServerFilters /// The filters can modify, log, or perform additional operations on requests and responses for /// requests. The handler should implement logic to fetch or generate the requested prompt and return appropriate results. /// - public List> GetPromptFilters { get; } = new(); + public List> GetPromptFilters { get; } = []; /// /// Gets the filters for the list resource templates handler pipeline. @@ -74,7 +74,7 @@ public sealed class McpServerFilters /// requests. It supports pagination through the cursor mechanism, /// where the client can make repeated calls with the cursor returned by the previous call to retrieve more resource templates. /// - public List> ListResourceTemplatesFilters { get; } = new(); + public List> ListResourceTemplatesFilters { get; } = []; /// /// Gets the filters for the list resources handler pipeline. @@ -85,7 +85,7 @@ public sealed class McpServerFilters /// requests. It supports pagination through the cursor mechanism, /// where the client can make repeated calls with the cursor returned by the previous call to retrieve more resources. /// - public List> ListResourcesFilters { get; } = new(); + public List> ListResourcesFilters { get; } = []; /// /// Gets the filters for the read resource handler pipeline. @@ -95,7 +95,7 @@ public sealed class McpServerFilters /// The filters can modify, log, or perform additional operations on requests and responses for /// requests. The handler should implement logic to locate and retrieve the requested resource. /// - public List> ReadResourceFilters { get; } = new(); + public List> ReadResourceFilters { get; } = []; /// /// Gets the filters for the complete handler pipeline. @@ -106,7 +106,7 @@ public sealed class McpServerFilters /// requests. The handler processes auto-completion requests, returning a list of suggestions based on the /// reference type and current argument value. /// - public List> CompleteFilters { get; } = new(); + public List> CompleteFilters { get; } = []; /// /// Gets the filters for the subscribe to resources handler pipeline. @@ -123,7 +123,7 @@ public sealed class McpServerFilters /// whenever a relevant resource is created, updated, or deleted. /// /// - public List> SubscribeToResourcesFilters { get; } = new(); + public List> SubscribeToResourcesFilters { get; } = []; /// /// Gets the filters for the unsubscribe from resources handler pipeline. @@ -140,7 +140,7 @@ public sealed class McpServerFilters /// to the client for the specified resources. /// /// - public List> UnsubscribeFromResourcesFilters { get; } = new(); + public List> UnsubscribeFromResourcesFilters { get; } = []; /// /// Gets the filters for the set logging level handler pipeline. @@ -157,5 +157,5 @@ public sealed class McpServerFilters /// at or above the specified level to the client as notifications/message notifications. /// /// - public List> SetLoggingLevelFilters { get; } = new(); + public List> SetLoggingLevelFilters { get; } = []; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 8e264741b..2646883b7 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -207,12 +207,9 @@ private void ConfigureInitialize(McpServerOptions options) // Otherwise, try to use whatever the client requested as long as it's supported. // If it's not supported, fall back to the latest supported version. string? protocolVersion = options.ProtocolVersion; - if (protocolVersion is null) - { - protocolVersion = request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? - clientProtocolVersion : - McpSessionHandler.LatestProtocolVersion; - } + protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? + clientProtocolVersion : + McpSessionHandler.LatestProtocolVersion; _negotiatedProtocolVersion = protocolVersion; diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 833c852e3..618e87d58 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -7,8 +7,6 @@ namespace ModelContextProtocol.Server; /// public sealed class McpServerOptions { - private McpServerHandlers? _handlers; - /// /// Gets or sets information about this server implementation, including its name and version. /// @@ -97,11 +95,11 @@ public sealed class McpServerOptions /// public McpServerHandlers Handlers { - get => _handlers ??= new(); + get => field ??= new(); set { Throw.IfNull(value); - _handlers = value; + field = value; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs index 146c0e063..434ed4052 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs @@ -90,7 +90,7 @@ public sealed class McpServerPromptCreateOptions /// Creates a shallow clone of the current instance. /// internal McpServerPromptCreateOptions Clone() => - new McpServerPromptCreateOptions + new() { Services = Services, Name = Name, diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs index c2ec444cd..c519828a0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs @@ -105,7 +105,7 @@ public sealed class McpServerResourceCreateOptions /// Creates a shallow clone of the current instance. /// internal McpServerResourceCreateOptions Clone() => - new McpServerResourceCreateOptions + new() { Services = Services, UriTemplate = UriTemplate, diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index cb4205be1..73f76bca7 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -176,7 +176,7 @@ public sealed class McpServerToolCreateOptions /// Creates a shallow clone of the current instance. /// internal McpServerToolCreateOptions Clone() => - new McpServerToolCreateOptions + new() { Services = Services, Name = Name, diff --git a/src/ModelContextProtocol.Core/Server/RequestContext.cs b/src/ModelContextProtocol.Core/Server/RequestContext.cs index f75cea80b..1240e37f6 100644 --- a/src/ModelContextProtocol.Core/Server/RequestContext.cs +++ b/src/ModelContextProtocol.Core/Server/RequestContext.cs @@ -17,8 +17,6 @@ public sealed class RequestContext /// The server with which this instance is associated. private McpServer _server; - private IDictionary? _items; - /// /// Initializes a new instance of the class with the specified server and JSON-RPC request. /// @@ -51,14 +49,8 @@ public McpServer Server /// public IDictionary Items { - get - { - return _items ??= new Dictionary(); - } - set - { - _items = value; - } + get => field ??= new Dictionary(); + set => field = value; } /// Gets or sets the services associated with this request. diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index a822b6f2a..27e2f0d8d 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -243,7 +243,7 @@ public static string FormatUri(string uriTemplate, IReadOnlyDictionary.Empty; + expression = []; } else { @@ -373,7 +373,7 @@ value as string ?? return builder.ToStringAndClear(); } - private static void AppendJoin(ref DefaultInterpolatedStringHandler builder, string separator, IList values) + private static void AppendJoin(ref DefaultInterpolatedStringHandler builder, string separator, List values) { int count = values.Count; if (count > 0) @@ -441,8 +441,8 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) else { #if NET - Span utf8 = stackalloc byte[Encoding.UTF8.GetMaxByteCount(1)]; - foreach (byte b in utf8.Slice(0, new Rune(c).EncodeToUtf8(utf8))) + Span utf8 = stackalloc byte[Encoding.UTF8.GetMaxByteCount(1)]; + foreach (byte b in utf8.Slice(0, new Rune(c).EncodeToUtf8(utf8))) #else foreach (byte b in Encoding.UTF8.GetBytes([c])) #endif diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 963ba0fed..b69108ab2 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0;netstandard2.0 + net10.0;net9.0;net8.0;netstandard2.0 true true ModelContextProtocol diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs index 073b2fd18..bbe1b43af 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -448,7 +448,7 @@ private async Task StartServerWithoutAuthFilters(Action new ClaimsPrincipal(new ClaimsIdentity( + => new(new ClaimsIdentity( [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name), .. roles.Select(role => new Claim("role", role))], "TestAuthType", "name", "role")); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index 166d492f2..d3700f57f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -241,7 +241,7 @@ public async Task Server_ShutsDownQuickly_WhenClientIsConnected() } private ClaimsPrincipal CreateUser(string name) - => new ClaimsPrincipal(new ClaimsIdentity( + => new(new ClaimsIdentity( [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)], "TestAuthType", "name", "role")); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 5cc7f74d8..3f45d52d4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -556,7 +556,7 @@ public async Task McpServer_UsedOutOfScope_CanSendNotifications() Assert.Equal(NotificationMethods.ResourceUpdatedNotification, notification.Method); } - private static StringContent JsonContent(string json) => new StringContent(json, Encoding.UTF8, "application/json"); + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); private static T AssertType(JsonNode? jsonNode) diff --git a/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs b/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs index 32472a883..a192bbf60 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs @@ -29,7 +29,7 @@ internal sealed class AuthorizationServerMetadata /// Gets the introspection endpoint URL. /// [JsonPropertyName("introspection_endpoint")] - public Uri? IntrospectionEndpoint => new Uri($"{Issuer}/introspect"); + public Uri? IntrospectionEndpoint => new($"{Issuer}/introspect"); /// /// Gets or sets the response types supported by this server. diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index e2eb3435f..9eef66400 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -56,8 +56,8 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } #endregion - List resources = new(); - List resourceContents = new(); + List resources = []; + List resourceContents = []; for (int i = 0; i < 100; ++i) { string uri = $"test://static/resource/{i + 1}"; @@ -319,7 +319,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st { throw new McpProtocolException("Missing required parameter 'name'", McpErrorCode.InvalidParams); } - List messages = new(); + List messages = []; if (request.Params.Name == "simple_prompt") { messages.Add(new PromptMessage diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index b0e7b6d07..55a3b4932 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -88,7 +88,7 @@ await RunConnected(async (client, server) => { await client.CallToolAsync("Throw", cancellationToken: TestContext.Current.CancellationToken); await Assert.ThrowsAsync(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken)); - }, new List()); + }, []); } Assert.NotEmpty(activities); diff --git a/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs b/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs index ff248ec17..7711ee6ca 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/IconTests.cs @@ -13,7 +13,7 @@ public static void Icon_SerializationRoundTrip_PreservesAllProperties() { Source = "https://example.com/icon.png", MimeType = "image/png", - Sizes = new List { "48x48" }, + Sizes = ["48x48"], Theme = "light" }; @@ -61,7 +61,7 @@ public static void Icon_HasCorrectJsonPropertyNames() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", - Sizes = new List { "any" }, + Sizes = ["any"], Theme = "dark" }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/ImplementationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ImplementationTests.cs index ff938eff9..e3fae24f4 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ImplementationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ImplementationTests.cs @@ -16,8 +16,8 @@ public static void Implementation_SerializationRoundTrip_PreservesAllProperties( Version = "1.0.0", Icons = [ - new() { Source = "https://example.com/icon.png", MimeType = "image/png", Sizes = new List { "48x48" } }, - new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = new List { "any" } } + new() { Source = "https://example.com/icon.png", MimeType = "image/png", Sizes = ["48x48"] }, + new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = ["any"] } ], WebsiteUrl = "https://example.com" }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/PromptTests.cs b/tests/ModelContextProtocol.Tests/Protocol/PromptTests.cs index e73ee4cf5..d3acf782a 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/PromptTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/PromptTests.cs @@ -16,7 +16,7 @@ public static void Prompt_SerializationRoundTrip_PreservesAllProperties() Description = "Review the provided code", Icons = [ - new() { Source = "https://example.com/review-icon.svg", MimeType = "image/svg+xml", Sizes = new List { "any" } } + new() { Source = "https://example.com/review-icon.svg", MimeType = "image/svg+xml", Sizes = ["any"] } ], Arguments = [ diff --git a/tests/ModelContextProtocol.Tests/Protocol/ResourceTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ResourceTests.cs index e0e71a036..59c076a69 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ResourceTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ResourceTests.cs @@ -19,7 +19,7 @@ public static void Resource_SerializationRoundTrip_PreservesAllProperties() Size = 1024, Icons = [ - new() { Source = "https://example.com/pdf-icon.png", MimeType = "image/png", Sizes = new List { "32x32" } } + new() { Source = "https://example.com/pdf-icon.png", MimeType = "image/png", Sizes = ["32x32"] } ], Annotations = new Annotations { Audience = [Role.User] } }; @@ -86,7 +86,7 @@ public static void Resource_HasCorrectJsonPropertyNames() Description = "A test resource", MimeType = "text/plain", Size = 512, - Icons = new List { new() { Source = "https://example.com/icon.svg" } }, + Icons = [new() { Source = "https://example.com/icon.svg" }], Annotations = new Annotations { Audience = [Role.User] } }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs index 630ced49c..75e837cc2 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs @@ -16,7 +16,7 @@ public static void Tool_SerializationRoundTrip_PreservesAllProperties() Description = "Get current weather information", Icons = [ - new() { Source = "https://example.com/weather.png", MimeType = "image/png", Sizes = new List { "48x48" } } + new() { Source = "https://example.com/weather.png", MimeType = "image/png", Sizes = ["48x48"] } ], Annotations = new ToolAnnotations { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs index 1cb7548db..b463514f9 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPromptTests.cs @@ -498,7 +498,7 @@ public void SupportsIconsInCreateOptions() { var icons = new List { - new() { Source = "https://example.com/prompt-icon.png", MimeType = "image/png", Sizes = new List { "48x48" } } + new() { Source = "https://example.com/prompt-icon.png", MimeType = "image/png", Sizes = ["48x48"] } }; McpServerPrompt prompt = McpServerPrompt.Create(() => "test prompt", new McpServerPromptCreateOptions diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs index c52778df1..135633b82 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs @@ -307,7 +307,7 @@ public async Task ResourceCollection_UsesCaseInsensitiveHostLookup() { McpServerResource t1 = McpServerResource.Create(() => "resource", new() { UriTemplate = "resource://MyCoolResource" }); McpServerResource t2 = McpServerResource.Create(() => "resource", new() { UriTemplate = "resource://MyCoolResource2" }); - McpServerResourceCollection collection = new() { t1, t2 }; + McpServerResourceCollection collection = [t1, t2]; Assert.True(collection.TryGetPrimitive("resource://mycoolresource", out McpServerResource? result)); Assert.Same(t1, result); } @@ -537,7 +537,7 @@ public async Task CanReturnReadResult() McpServerResource resource = McpServerResource.Create((McpServer server) => { Assert.Same(mockServer.Object, server); - return new ReadResourceResult { Contents = new List { new TextResourceContents { Text = "hello" } } }; + return new ReadResourceResult { Contents = [new TextResourceContents { Text = "hello" }] }; }, new() { Name = "Test" }); var result = await resource.ReadAsync( new RequestContext(mockServer.Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = "resource://mcp/Test" } }, @@ -682,7 +682,7 @@ public void SupportsIconsInResourceCreateOptions() { var icons = new List { - new() { Source = "https://example.com/resource-icon.png", MimeType = "image/png", Sizes = new List { "32x32" } } + new() { Source = "https://example.com/resource-icon.png", MimeType = "image/png", Sizes = ["32x32"] } }; McpServerResource resource = McpServerResource.Create(() => "test content", new McpServerResourceCreateOptions diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 111d13430..4a9f76ae7 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -413,7 +413,7 @@ public async Task CanReturnCallToolResult() { CallToolResult response = new() { - Content = new List { new TextContentBlock { Text = "text" }, new ImageContentBlock { Data = "1234", MimeType = "image/png" } } + Content = [new TextContentBlock { Text = "text" }, new ImageContentBlock { Data = "1234", MimeType = "image/png" }] }; Mock mockServer = new(); @@ -683,8 +683,8 @@ public void SupportsIconsInCreateOptions() { var icons = new List { - new() { Source = "https://example.com/icon.png", MimeType = "image/png", Sizes = new List { "48x48" } }, - new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = new List { "any" } } + new() { Source = "https://example.com/icon.png", MimeType = "image/png", Sizes = ["48x48"] }, + new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = ["any"] } }; McpServerTool tool = McpServerTool.Create(() => "test", new McpServerToolCreateOptions