diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..2746f0762
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,22 @@
+{
+ "name": "C# (.NET SDK)",
+ "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0-jammy",
+ "features": {
+ "ghcr.io/devcontainers/features/dotnet:2": {
+ "version": "9.0"
+ },
+ "ghcr.io/devcontainers/features/node:1": {}
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-dotnettools.csharp",
+ "ms-dotnettools.csdevkit"
+ ],
+ "settings": {
+ "dotnet.defaultSolution": "ModelContextProtocol.slnx"
+ }
+ }
+ },
+ "postCreateCommand": "dotnet --list-sdks && echo 'Available .NET SDKs installed successfully!'"
+}
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
index 3ce6343ba..99b4fbaf1 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -3,6 +3,7 @@ root = true
# C# files
[*.cs]
+csharp_style_namespace_declarations=file_scoped:warning
# Compiler
dotnet_diagnostic.CS1998.severity = suggestion # CS1998: Missing awaits
diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml
index 9a66ccd07..b29bccd53 100644
--- a/.github/workflows/ci-build-test.yml
+++ b/.github/workflows/ci-build-test.yml
@@ -18,6 +18,7 @@ on:
- "src/**"
- "tests/**"
- "samples/**"
+ - "docs/**"
permissions:
contents: read
@@ -50,6 +51,10 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get install -y mono-devel
+ - name: Setup Mono on macOS
+ if: runner.os == 'macOS'
+ run: brew install mono
+
- name: Set up Node.js
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml
index 6a49bec6a..b69bbc440 100644
--- a/.github/workflows/markdown-link-check.yml
+++ b/.github/workflows/markdown-link-check.yml
@@ -21,5 +21,5 @@ jobs:
- name: Markup Link Checker (mlc)
uses: becheran/mlc@c925f90a9a25e16e4c4bfa29058f6f9ffa9f0d8c # v0.21.0
with:
- # Ignore external links that result in 403 errors during CI. Do not warn for redirects where we want to keep the vanity URL in the markdown or for GitHub links that redirect to the login.
- args: --ignore-links "https://www.anthropic.com/*,https://hackerone.com/anthropic-vdp/*" --do-not-warn-for-redirect-to "https://modelcontextprotocol.io/*,https://github.com/login?*" ./
+ # Ignore external links that result in 403 errors during CI. Do not warn for redirects where we want to keep the vanity URL in the markdown or for GitHub links that redirect to the login, and DocFX snippet links.
+ args: --ignore-links "https://www.anthropic.com/*,https://hackerone.com/anthropic-vdp/*" --do-not-warn-for-redirect-to "https://modelcontextprotocol.io/*,https://github.com/login?*" --ignore-links "*samples/*?name=snippet_*" ./docs
diff --git a/Directory.Build.props b/Directory.Build.props
index bca375922..b0cf7f215 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -34,4 +34,9 @@
true
+
+
+
+ true
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6da9521f7..1ae45da31 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,7 +3,7 @@
true
9.0.5
10.0.0-preview.4.25258.110
- 9.7.1
+ 9.9.1
@@ -47,13 +47,13 @@
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
@@ -66,8 +66,8 @@
-
-
+
+
@@ -75,9 +75,9 @@
-
-
+
+
-
+
\ No newline at end of file
diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx
index 5ed8ba0d6..a70e3e310 100644
--- a/ModelContextProtocol.slnx
+++ b/ModelContextProtocol.slnx
@@ -8,12 +8,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
+
+
+
@@ -24,6 +56,7 @@
+
diff --git a/README.md b/README.md
index 163d57f8a..4c87ba9bd 100644
--- a/README.md
+++ b/README.md
@@ -37,8 +37,8 @@ dotnet add package ModelContextProtocol --prerelease
## Getting Started (Client)
-To get started writing a client, the `McpClientFactory.CreateAsync` method is used to instantiate and connect an `IMcpClient`
-to a server. Once you have an `IMcpClient`, you can interact with it, such as to enumerate all available tools and invoke tools.
+To get started writing a client, the `McpClient.CreateAsync` method is used to instantiate and connect an `McpClient`
+to a server. Once you have an `McpClient`, you can interact with it, such as to enumerate all available tools and invoke tools.
```csharp
var clientTransport = new StdioClientTransport(new StdioClientTransportOptions
@@ -48,7 +48,7 @@ var clientTransport = new StdioClientTransport(new StdioClientTransportOptions
Arguments = ["-y", "@modelcontextprotocol/server-everything"],
});
-var client = await McpClientFactory.CreateAsync(clientTransport);
+var client = await McpClient.CreateAsync(clientTransport);
// Print the list of tools available from the server.
foreach (var tool in await client.ListToolsAsync())
@@ -88,7 +88,7 @@ var response = await chatClient.GetResponseAsync(
Here is an example of how to create an MCP server and register all tools from the current application.
It includes a simple echo tool as an example (this is included in the same file here for easy of copy and paste, but it needn't be in the same file...
the employed overload of `WithTools` examines the current assembly for classes with the `McpServerToolType` attribute, and registers all methods with the
-`McpTool` attribute as tools.)
+`McpServerTool` attribute as tools.)
```
dotnet add package ModelContextProtocol --prerelease
@@ -122,14 +122,14 @@ public static class EchoTool
}
```
-Tools can have the `IMcpServer` representing the server injected via a parameter to the method, and can use that for interaction with
+Tools can have the `McpServer` representing the server injected via a parameter to the method, and can use that for interaction with
the connected client. Similarly, arguments may be injected via dependency injection. For example, this tool will use the supplied
-`IMcpServer` to make sampling requests back to the client in order to summarize content it downloads from the specified url via
+`McpServer` to make sampling requests back to the client in order to summarize content it downloads from the specified url via
an `HttpClient` injected via dependency injection.
```csharp
[McpServerTool(Name = "SummarizeContentFromUrl"), Description("Summarizes content downloaded from a specific URI")]
public static async Task SummarizeDownloadedContent(
- IMcpServer thisServer,
+ McpServer thisServer,
HttpClient httpClient,
[Description("The url from which to download the content to summarize")] string url,
CancellationToken cancellationToken)
@@ -174,57 +174,54 @@ using System.Text.Json;
McpServerOptions options = new()
{
ServerInfo = new Implementation { Name = "MyServer", Version = "1.0.0" },
- Capabilities = new ServerCapabilities
+ Handlers = new McpServerHandlers()
{
- Tools = new ToolsCapability
- {
- ListToolsHandler = (request, cancellationToken) =>
- ValueTask.FromResult(new ListToolsResult
- {
- Tools =
- [
- new Tool
- {
- Name = "echo",
- Description = "Echoes the input back to the client.",
- InputSchema = JsonSerializer.Deserialize("""
- {
- "type": "object",
- "properties": {
- "message": {
- "type": "string",
- "description": "The input to echo back"
- }
- },
- "required": ["message"]
- }
- """),
- }
- ]
- }),
-
- CallToolHandler = (request, cancellationToken) =>
+ ListToolsHandler = (request, cancellationToken) =>
+ ValueTask.FromResult(new ListToolsResult
{
- if (request.Params?.Name == "echo")
- {
- if (request.Params.Arguments?.TryGetValue("message", out var message) is not true)
+ Tools =
+ [
+ new Tool
{
- throw new McpException("Missing required argument 'message'");
+ Name = "echo",
+ Description = "Echoes the input back to the client.",
+ InputSchema = JsonSerializer.Deserialize("""
+ {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "description": "The input to echo back"
+ }
+ },
+ "required": ["message"]
+ }
+ """),
}
+ ]
+ }),
- return ValueTask.FromResult(new CallToolResult
- {
- Content = [new TextContentBlock { Text = $"Echo: {message}", Type = "text" }]
- });
+ CallToolHandler = (request, cancellationToken) =>
+ {
+ if (request.Params?.Name == "echo")
+ {
+ if (request.Params.Arguments?.TryGetValue("message", out var message) is not true)
+ {
+ throw new McpException("Missing required argument 'message'");
}
- throw new McpException($"Unknown tool: '{request.Params?.Name}'");
- },
+ return ValueTask.FromResult(new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = $"Echo: {message}", Type = "text" }]
+ });
+ }
+
+ throw new McpException($"Unknown tool: '{request.Params?.Name}'");
}
- },
+ }
};
-await using IMcpServer server = McpServerFactory.Create(new StdioServerTransport("MyServer"), options);
+await using McpServer server = McpServer.Create(new StdioServerTransport("MyServer"), options);
await server.RunAsync();
```
diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md
new file mode 100644
index 000000000..ebda0979a
--- /dev/null
+++ b/docs/concepts/elicitation/elicitation.md
@@ -0,0 +1,53 @@
+---
+title: Elicitation
+author: mikekistler
+description: Enable interactive AI experiences by requesting user input during tool execution.
+uid: elicitation
+---
+
+## Elicitation
+
+The **elicitation** feature allows servers to request additional information from users during interactions. This enables more dynamic and interactive AI experiences, making it easier to gather necessary context before executing tasks.
+
+### 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
+
+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.
+The schema may include a description to help the user understand what is being requested.
+
+The server can request a single input or multiple inputs at once.
+To help distinguish multiple inputs, each input has a unique name.
+
+The following example demonstrates how a server could request a boolean response from the user.
+
+[!code-csharp[](samples/server/Tools/InteractiveTools.cs?name=snippet_GuessTheNumber)]
+
+### 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
+
+[!code-csharp[](samples/client/Program.cs?name=snippet_McpInitialize)]
+
+The ElicitationHandler is an asynchronous method that will be called when the server requests additional information.
+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
+
+Below is an example of how a console application might handle elicitation requests.
+Here's an example implementation:
+
+[!code-csharp[](samples/client/Program.cs?name=snippet_ElicitationHandler)]
diff --git a/docs/concepts/elicitation/samples/client/ElicitationClient.csproj b/docs/concepts/elicitation/samples/client/ElicitationClient.csproj
new file mode 100644
index 000000000..4be0d6ec7
--- /dev/null
+++ b/docs/concepts/elicitation/samples/client/ElicitationClient.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
diff --git a/docs/concepts/elicitation/samples/client/Program.cs b/docs/concepts/elicitation/samples/client/Program.cs
new file mode 100644
index 000000000..b2a91ca4b
--- /dev/null
+++ b/docs/concepts/elicitation/samples/client/Program.cs
@@ -0,0 +1,118 @@
+using System.Text.Json;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+
+var endpoint = Environment.GetEnvironmentVariable("ENDPOINT") ?? "http://localhost:3001";
+
+var clientTransport = new HttpClientTransport(new()
+{
+ Endpoint = new Uri(endpoint),
+ TransportMode = HttpTransportMode.StreamableHttp,
+});
+
+//
+McpClientOptions options = new()
+{
+ ClientInfo = new()
+ {
+ Name = "ElicitationClient",
+ Version = "1.0.0"
+ },
+ Handlers = new()
+ {
+ ElicitationHandler = HandleElicitationAsync
+ }
+};
+
+await using var mcpClient = await McpClient.CreateAsync(clientTransport, options);
+//
+
+var tools = await mcpClient.ListToolsAsync();
+foreach (var tool in tools)
+{
+ Console.WriteLine($"Connected to server with tools: {tool.Name}");
+}
+
+Console.WriteLine($"Calling tool: {tools.First().Name}");
+
+var result = await mcpClient.CallToolAsync(toolName: tools.First().Name);
+
+foreach (var block in result.Content)
+{
+ if (block is TextContentBlock textBlock)
+ {
+ Console.WriteLine(textBlock.Text);
+ }
+ else
+ {
+ Console.WriteLine($"Received unexpected result content of type {block.GetType()}");
+ }
+}
+
+//
+async ValueTask HandleElicitationAsync(ElicitRequestParams? requestParams, CancellationToken token)
+{
+ // Bail out if the requestParams is null or if the requested schema has no properties
+ if (requestParams is null || requestParams.RequestedSchema?.Properties is null)
+ {
+ return new ElicitResult();
+ }
+
+ // Process the elicitation request
+ if (requestParams.Message is not null)
+ {
+ Console.WriteLine(requestParams.Message);
+ }
+
+ var content = new Dictionary();
+
+ // Loop through requestParams.requestSchema.Properties dictionary requesting values for each property
+ foreach (var property in requestParams.RequestedSchema.Properties)
+ {
+ if (property.Value is ElicitRequestParams.BooleanSchema booleanSchema)
+ {
+ Console.Write($"{booleanSchema.Description}: ");
+ var clientInput = Console.ReadLine();
+ bool parsedBool;
+
+ // Try standard boolean parsing first
+ if (bool.TryParse(clientInput, out parsedBool))
+ {
+ content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(parsedBool));
+ }
+ // Also accept "yes"/"no" as valid boolean inputs
+ else if (string.Equals(clientInput?.Trim(), "yes", StringComparison.OrdinalIgnoreCase))
+ {
+ content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(true));
+ }
+ else if (string.Equals(clientInput?.Trim(), "no", StringComparison.OrdinalIgnoreCase))
+ {
+ content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(false));
+ }
+ }
+ else if (property.Value is ElicitRequestParams.NumberSchema numberSchema)
+ {
+ Console.Write($"{numberSchema.Description}: ");
+ var clientInput = Console.ReadLine();
+ double parsedNumber;
+ if (double.TryParse(clientInput, out parsedNumber))
+ {
+ content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(parsedNumber));
+ }
+ }
+ else if (property.Value is ElicitRequestParams.StringSchema stringSchema)
+ {
+ Console.Write($"{stringSchema.Description}: ");
+ var clientInput = Console.ReadLine();
+ content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(clientInput));
+ }
+ }
+
+ // Return the user's input
+ return new ElicitResult
+ {
+ Action = "accept",
+ Content = content
+ };
+}
+//
diff --git a/docs/concepts/elicitation/samples/server/Elicitation.csproj b/docs/concepts/elicitation/samples/server/Elicitation.csproj
new file mode 100644
index 000000000..f4998aa12
--- /dev/null
+++ b/docs/concepts/elicitation/samples/server/Elicitation.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
diff --git a/docs/concepts/elicitation/samples/server/Elicitation.http b/docs/concepts/elicitation/samples/server/Elicitation.http
new file mode 100644
index 000000000..04dcdb343
--- /dev/null
+++ b/docs/concepts/elicitation/samples/server/Elicitation.http
@@ -0,0 +1,58 @@
+@HostAddress = http://localhost:3001
+
+# No session ID, so elicitation capabilities not declared.
+
+POST {{HostAddress}}/
+Accept: application/json, text/event-stream
+Content-Type: application/json
+MCP-Protocol-Version: 2025-06-18
+
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "guess_the_number"
+ }
+}
+
+###
+
+POST {{HostAddress}}/
+Accept: application/json, text/event-stream
+Content-Type: application/json
+
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "clientInfo": {
+ "name": "RestClient",
+ "version": "0.1.0"
+ },
+ "capabilities": {
+ "elicitation": {}
+ },
+ "protocolVersion": "2025-06-18"
+ }
+}
+
+###
+
+@SessionId = lgEu87uKTy8kLffZayO5rQ
+
+POST {{HostAddress}}/
+Accept: application/json, text/event-stream
+Content-Type: application/json
+Mcp-Session-Id: {{SessionId}}
+MCP-Protocol-Version: 2025-06-18
+
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "guess_the_number"
+ }
+}
diff --git a/docs/concepts/elicitation/samples/server/Program.cs b/docs/concepts/elicitation/samples/server/Program.cs
new file mode 100644
index 000000000..8c6862464
--- /dev/null
+++ b/docs/concepts/elicitation/samples/server/Program.cs
@@ -0,0 +1,24 @@
+using Elicitation.Tools;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddMcpServer()
+ .WithHttpTransport(options =>
+ options.IdleTimeout = Timeout.InfiniteTimeSpan // Never timeout
+ )
+ .WithTools();
+
+builder.Logging.AddConsole(options =>
+{
+ options.LogToStandardErrorThreshold = LogLevel.Information;
+});
+
+var app = builder.Build();
+
+app.UseHttpsRedirection();
+
+app.MapMcp();
+
+app.Run();
diff --git a/samples/AspNetCoreSseServer/Properties/launchSettings.json b/docs/concepts/elicitation/samples/server/Properties/launchSettings.json
similarity index 85%
rename from samples/AspNetCoreSseServer/Properties/launchSettings.json
rename to docs/concepts/elicitation/samples/server/Properties/launchSettings.json
index c789fb474..74cf457ef 100644
--- a/samples/AspNetCoreSseServer/Properties/launchSettings.json
+++ b/docs/concepts/elicitation/samples/server/Properties/launchSettings.json
@@ -1,4 +1,4 @@
-{
+{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
@@ -7,7 +7,6 @@
"applicationUrl": "http://localhost:3001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
- "OTEL_SERVICE_NAME": "sse-server",
}
},
"https": {
@@ -16,8 +15,7 @@
"applicationUrl": "https://localhost:7133;http://localhost:3001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
- "OTEL_SERVICE_NAME": "sse-server",
}
}
}
-}
+}
\ No newline at end of file
diff --git a/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs b/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs
new file mode 100644
index 000000000..b907a805d
--- /dev/null
+++ b/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs
@@ -0,0 +1,126 @@
+using System.ComponentModel;
+using System.Text.Json;
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using static ModelContextProtocol.Protocol.ElicitRequestParams;
+
+namespace Elicitation.Tools;
+
+[McpServerToolType]
+public sealed class InteractiveTools
+{
+ //
+ [McpServerTool, Description("A simple game where the user has to guess a number between 1 and 10.")]
+ public async Task GuessTheNumber(
+ McpServer server, // Get the McpServer from DI container
+ CancellationToken token
+ )
+ {
+ // Check if the client supports elicitation
+ if (server.ClientCapabilities?.Elicitation == null)
+ {
+ // fail the tool call
+ throw new McpException("Client does not support elicitation");
+ }
+
+ // First ask the user if they want to play
+ var playSchema = new RequestSchema
+ {
+ Properties =
+ {
+ ["Answer"] = new BooleanSchema()
+ }
+ };
+
+ var playResponse = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "Do you want to play a game?",
+ RequestedSchema = playSchema
+ }, token);
+
+ // Check if user wants to play
+ if (playResponse.Action != "accept" || playResponse.Content?["Answer"].ValueKind != JsonValueKind.True)
+ {
+ return "Maybe next time!";
+ }
+ //
+
+ // Now ask the user to enter their name
+ var nameSchema = new RequestSchema
+ {
+ Properties =
+ {
+ ["Name"] = new StringSchema()
+ {
+ Description = "Name of the player",
+ MinLength = 2,
+ MaxLength = 50,
+ }
+ }
+ };
+
+ var nameResponse = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = "What is your name?",
+ RequestedSchema = nameSchema
+ }, token);
+
+ if (nameResponse.Action != "accept")
+ {
+ return "Maybe next time!";
+ }
+ string? playerName = nameResponse.Content?["Name"].GetString();
+
+ // Generate a random number between 1 and 10
+ Random random = new Random();
+ int targetNumber = random.Next(1, 11); // 1 to 10 inclusive
+ int attempts = 0;
+
+ var message = "Guess a number between 1 and 10";
+
+ while (true)
+ {
+ attempts++;
+
+ var guessSchema = new RequestSchema
+ {
+ Properties =
+ {
+ ["Guess"] = new NumberSchema()
+ {
+ Type = "integer",
+ Minimum = 1,
+ Maximum = 10,
+ }
+ }
+ };
+
+ var guessResponse = await server.ElicitAsync(new ElicitRequestParams
+ {
+ Message = message,
+ RequestedSchema = guessSchema
+ }, token);
+
+ if (guessResponse.Action != "accept")
+ {
+ return "Maybe next time!";
+ }
+ int guess = (int)(guessResponse.Content?["Guess"].GetInt32())!;
+
+ // Check if the guess is correct
+ if (guess == targetNumber)
+ {
+ return $"Congratulations {playerName}! You guessed the number {targetNumber} in {attempts} attempts!";
+ }
+ else if (guess < targetNumber)
+ {
+ message = $"Your guess is too low! Try again (Attempt #{attempts}):";
+ }
+ else
+ {
+ message = $"Your guess is too high! Try again (Attempt #{attempts}):";
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md
new file mode 100644
index 000000000..3e081d123
--- /dev/null
+++ b/docs/concepts/filters.md
@@ -0,0 +1,317 @@
+---
+title: Filters
+author: halter73
+description: MCP Server Handler Filters
+uid: filters
+---
+
+# MCP Server Handler Filters
+
+For each handler type in the MCP Server, there are corresponding `AddXXXFilter` methods in `McpServerBuilderExtensions.cs` that allow you to add filters to the handler pipeline. The filters are stored in `McpServerOptions.Filters` and applied during server configuration.
+
+## Available Filter Methods
+
+The following filter methods are available:
+
+- `AddListResourceTemplatesFilter` - Filter for list resource templates handlers
+- `AddListToolsFilter` - Filter for list tools handlers
+- `AddCallToolFilter` - Filter for call tool handlers
+- `AddListPromptsFilter` - Filter for list prompts handlers
+- `AddGetPromptFilter` - Filter for get prompt handlers
+- `AddListResourcesFilter` - Filter for list resources handlers
+- `AddReadResourceFilter` - Filter for read resource handlers
+- `AddCompleteFilter` - Filter for completion handlers
+- `AddSubscribeToResourcesFilter` - Filter for resource subscription handlers
+- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers
+- `AddSetLoggingLevelFilter` - Filter for logging level handlers
+
+## Usage
+
+Filters are functions that take a handler and return a new handler, allowing you to wrap the original handler with additional functionality:
+
+```csharp
+services.AddMcpServer()
+ .WithListToolsHandler(async (context, cancellationToken) =>
+ {
+ // Your base handler logic
+ return new ListToolsResult { Tools = GetTools() };
+ })
+ .AddListToolsFilter(next => async (context, cancellationToken) =>
+ {
+ var logger = context.Services?.GetService>();
+
+ // Pre-processing logic
+ logger?.LogInformation("Before handler execution");
+
+ var result = await next(context, cancellationToken);
+
+ // Post-processing logic
+ logger?.LogInformation("After handler execution");
+ return result;
+ });
+```
+
+## Filter Execution Order
+
+```csharp
+services.AddMcpServer()
+ .WithListToolsHandler(baseHandler)
+ .AddListToolsFilter(filter1) // Executes first (outermost)
+ .AddListToolsFilter(filter2) // Executes second
+ .AddListToolsFilter(filter3); // Executes third (closest to handler)
+```
+
+Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filter2 -> filter1`
+
+## Common Use Cases
+
+### Logging
+```csharp
+.AddListToolsFilter(next => async (context, cancellationToken) =>
+{
+ var logger = context.Services?.GetService>();
+
+ logger?.LogInformation($"Processing request from {context.Meta.ProgressToken}");
+ var result = await next(context, cancellationToken);
+ logger?.LogInformation($"Returning {result.Tools?.Count ?? 0} tools");
+ return result;
+});
+```
+
+### Error Handling
+```csharp
+.AddCallToolFilter(next => async (context, cancellationToken) =>
+{
+ try
+ {
+ return await next(context, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ return new CallToolResult
+ {
+ Content = new[] { new TextContent { Type = "text", Text = $"Error: {ex.Message}" } },
+ IsError = true
+ };
+ }
+});
+```
+
+### Performance Monitoring
+```csharp
+.AddListToolsFilter(next => async (context, cancellationToken) =>
+{
+ var logger = context.Services?.GetService>();
+
+ var stopwatch = Stopwatch.StartNew();
+ var result = await next(context, cancellationToken);
+ stopwatch.Stop();
+ logger?.LogInformation($"Handler took {stopwatch.ElapsedMilliseconds}ms");
+ return result;
+});
+```
+
+### Caching
+```csharp
+.AddListResourcesFilter(next => async (context, cancellationToken) =>
+{
+ var cache = context.Services!.GetRequiredService();
+
+ var cacheKey = $"resources:{context.Params.Cursor}";
+ if (cache.TryGetValue(cacheKey, out var cached))
+ {
+ return (ListResourcesResult)cached;
+ }
+
+ var result = await next(context, cancellationToken);
+ cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
+ return result;
+});
+```
+
+## Built-in Authorization Filters
+
+When using the ASP.NET Core integration (`ModelContextProtocol.AspNetCore`), you can add authorization filters to support `[Authorize]` and `[AllowAnonymous]` attributes on MCP server tools, prompts, and resources by calling `AddAuthorizationFilters()` on your MCP server builder.
+
+### Enabling Authorization Filters
+
+To enable authorization support, call `AddAuthorizationFilters()` when configuring your MCP server:
+
+```csharp
+services.AddMcpServer()
+ .WithHttpTransport()
+ .AddAuthorizationFilters() // Enable authorization filter support
+ .WithTools();
+```
+
+**Important**: You should always call `AddAuthorizationFilters()` when using ASP.NET Core integration if you want to use authorization attributes like `[Authorize]` on your MCP server tools, prompts, or resources.
+
+### Authorization Attributes Support
+
+The MCP server automatically respects the following authorization attributes:
+
+- **`[Authorize]`** - Requires authentication for access
+- **`[Authorize(Roles = "RoleName")]`** - Requires specific roles
+- **`[Authorize(Policy = "PolicyName")]`** - Requires specific authorization policies
+- **`[AllowAnonymous]`** - Explicitly allows anonymous access (overrides `[Authorize]`)
+
+### Tool Authorization
+
+Tools can be decorated with authorization attributes to control access:
+
+```csharp
+[McpServerToolType]
+public class WeatherTools
+{
+ [McpServerTool, Description("Gets public weather data")]
+ public static string GetWeather(string location)
+ {
+ return $"Weather for {location}: Sunny, 25°C";
+ }
+
+ [McpServerTool, Description("Gets detailed weather forecast")]
+ [Authorize] // Requires authentication
+ public static string GetDetailedForecast(string location)
+ {
+ return $"Detailed forecast for {location}: ...";
+ }
+
+ [McpServerTool, Description("Manages weather alerts")]
+ [Authorize(Roles = "Admin")] // Requires Admin role
+ public static string ManageWeatherAlerts(string alertType)
+ {
+ return $"Managing alert: {alertType}";
+ }
+}
+```
+
+### Class-Level Authorization
+
+You can apply authorization at the class level, which affects all tools in the class:
+
+```csharp
+[McpServerToolType]
+[Authorize] // All tools require authentication
+public class RestrictedTools
+{
+ [McpServerTool, Description("Restricted tool accessible to authenticated users")]
+ public static string RestrictedOperation()
+ {
+ return "Restricted operation completed";
+ }
+
+ [McpServerTool, Description("Public tool accessible to anonymous users")]
+ [AllowAnonymous] // Overrides class-level [Authorize]
+ public static string PublicOperation()
+ {
+ return "Public operation completed";
+ }
+}
+```
+
+### How Authorization Filters Work
+
+The authorization filters work differently for list operations versus individual operations:
+
+#### List Operations (ListTools, ListPrompts, ListResources)
+For list operations, the filters automatically remove unauthorized items from the results. Users only see tools, prompts, or resources they have permission to access.
+
+#### Individual Operations (CallTool, GetPrompt, ReadResource)
+For individual operations, the filters throw an `McpException` with "Access forbidden" message. These get turned into JSON-RPC errors if uncaught by middleware.
+
+### Filter Execution Order and Authorization
+
+Authorization filters are applied automatically when you call `AddAuthorizationFilters()`. These filters run at a specific point in the filter pipeline, which means:
+
+**Filters added before authorization filters** can see:
+- Unauthorized requests for operations before they are rejected by the authorization filters
+- Complete listings for unauthorized primitives before they are filtered out by the authorization filters
+
+**Filters added after authorization filters** will only see:
+- Authorized requests that passed authorization checks
+- Filtered listings containing only authorized primitives
+
+This allows you to implement logging, metrics, or other cross-cutting concerns that need to see all requests, while still maintaining proper authorization:
+
+```csharp
+services.AddMcpServer()
+ .WithHttpTransport()
+ .AddListToolsFilter(next => async (context, cancellationToken) =>
+ {
+ var logger = context.Services?.GetService>();
+
+ // This filter runs BEFORE authorization - sees all tools
+ logger?.LogInformation("Request for tools list - will see all tools");
+ var result = await next(context, cancellationToken);
+ logger?.LogInformation($"Returning {result.Tools?.Count ?? 0} tools after authorization");
+ return result;
+ })
+ .AddAuthorizationFilters() // Authorization filtering happens here
+ .AddListToolsFilter(next => async (context, cancellationToken) =>
+ {
+ var logger = context.Services?.GetService>();
+
+ // This filter runs AFTER authorization - only sees authorized tools
+ var result = await next(context, cancellationToken);
+ logger?.LogInformation($"Post-auth filter sees {result.Tools?.Count ?? 0} authorized tools");
+ return result;
+ })
+ .WithTools();
+```
+
+### Setup Requirements
+
+To use authorization features, you must configure authentication and authorization in your ASP.NET Core application and call `AddAuthorizationFilters()`:
+
+```csharp
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddAuthentication("Bearer")
+ .AddJwtBearer(options => { /* JWT configuration */ })
+ .AddMcp(options => { /* Resource metadata configuration */ });
+builder.Services.AddAuthorization();
+
+builder.Services.AddMcpServer()
+ .WithHttpTransport()
+ .AddAuthorizationFilters() // Required for authorization support
+ .WithTools()
+ .AddCallToolFilter(next => async (context, cancellationToken) =>
+ {
+ // Custom call tool logic
+ return await next(context, cancellationToken);
+ });
+
+var app = builder.Build();
+
+app.MapMcp();
+app.Run();
+```
+
+### Custom Authorization Filters
+
+You can also create custom authorization filters using the filter methods:
+
+```csharp
+.AddCallToolFilter(next => async (context, cancellationToken) =>
+{
+ // Custom authorization logic
+ if (context.User?.Identity?.IsAuthenticated != true)
+ {
+ return new CallToolResult
+ {
+ Content = [new TextContent { Text = "Custom: Authentication required" }],
+ IsError = true
+ };
+ }
+
+ return await next(context, cancellationToken);
+});
+```
+
+### RequestContext
+
+Within filters, you have access to:
+
+- `context.User` - The current user's `ClaimsPrincipal`
+- `context.Services` - The request's service provider for resolving authorization services
+- `context.MatchedPrimitive` - The matched tool/prompt/resource with its metadata including authorization attributes via `context.MatchedPrimitive.Metadata`
diff --git a/docs/concepts/httpcontext/httpcontext.md b/docs/concepts/httpcontext/httpcontext.md
new file mode 100644
index 000000000..51bbd050a
--- /dev/null
+++ b/docs/concepts/httpcontext/httpcontext.md
@@ -0,0 +1,31 @@
+---
+title: HTTP Context
+author: mikekistler
+description: How to access the HttpContext in the MCP C# SDK.
+uid: httpcontext
+---
+
+## HTTP Context
+
+When using the Streamable HTTP transport, an MCP server may need to access the underlying [HttpContext] for a request.
+The [HttpContext] contains request metadata such as the HTTP headers, authorization context, and the actual path and query string for the request.
+
+To access the [HttpContext], the MCP server should add the [IHttpContextAccessor] service to the application service collection (typically in Program.cs).
+Then any classes, e.g. a class containing MCP tools, should accept an [IHttpContextAccessor] in their constructor and store this for use by its methods.
+Methods then use the [HttpContext property][IHttpContextAccessor.HttpContext] of the accessor to get the current context.
+
+[HttpContext]: https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.httpcontext
+[IHttpContextAccessor]: https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.ihttpcontextaccessor
+[IHttpContextAccessor.HttpContext]: https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.ihttpcontextaccessor.httpcontext
+
+The following code snippet illustrates how to add the [IHttpContextAccessor] service to the application service collection:
+
+[!code-csharp[](samples/Program.cs?name=snippet_AddHttpContextAccessor)]
+
+Any class that needs access to the [HttpContext] can accept an [IHttpContextAccessor] in its constructor and store it for later use.
+Methods of the class can then access the current [HttpContext] using the stored accessor.
+
+The following code snippet shows the `ContextTools` class accepting an [IHttpContextAccessor] in its primary constructor
+and the `GetHttpHeaders` method accessing the current [HttpContext] to retrieve the HTTP headers from the current request.
+
+[!code-csharp[](samples/Tools/ContextTools.cs?name=snippet_AccessHttpContext)]
diff --git a/docs/concepts/httpcontext/samples/HttpContext.csproj b/docs/concepts/httpcontext/samples/HttpContext.csproj
new file mode 100644
index 000000000..2982d8f87
--- /dev/null
+++ b/docs/concepts/httpcontext/samples/HttpContext.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+ false
+ false
+
+
+
+
+
+
+
diff --git a/docs/concepts/httpcontext/samples/HttpContext.http b/docs/concepts/httpcontext/samples/HttpContext.http
new file mode 100644
index 000000000..838457e9b
--- /dev/null
+++ b/docs/concepts/httpcontext/samples/HttpContext.http
@@ -0,0 +1,15 @@
+@HostAddress = http://localhost:3001
+
+POST {{HostAddress}}/
+Accept: application/json, text/event-stream
+Content-Type: application/json
+MCP-Protocol-Version: 2025-06-18
+
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "get_http_headers"
+ }
+}
diff --git a/docs/concepts/httpcontext/samples/Program.cs b/docs/concepts/httpcontext/samples/Program.cs
new file mode 100644
index 000000000..043e6069d
--- /dev/null
+++ b/docs/concepts/httpcontext/samples/Program.cs
@@ -0,0 +1,26 @@
+using HttpContext.Tools;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddMcpServer()
+ .WithHttpTransport()
+ .WithTools();
+
+//
+builder.Services.AddHttpContextAccessor();
+//
+
+builder.Logging.AddConsole(options =>
+{
+ options.LogToStandardErrorThreshold = LogLevel.Information;
+});
+
+var app = builder.Build();
+
+app.UseHttpsRedirection();
+
+app.MapMcp();
+
+app.Run();
diff --git a/docs/concepts/httpcontext/samples/Properties/launchSettings.json b/docs/concepts/httpcontext/samples/Properties/launchSettings.json
new file mode 100644
index 000000000..c6eb0fa56
--- /dev/null
+++ b/docs/concepts/httpcontext/samples/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7191;http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/docs/concepts/httpcontext/samples/Tools/ContextTools.cs b/docs/concepts/httpcontext/samples/Tools/ContextTools.cs
new file mode 100644
index 000000000..222130bb9
--- /dev/null
+++ b/docs/concepts/httpcontext/samples/Tools/ContextTools.cs
@@ -0,0 +1,28 @@
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+
+namespace HttpContext.Tools;
+
+//
+public class ContextTools(IHttpContextAccessor httpContextAccessor)
+{
+ [McpServerTool(UseStructuredContent = true)]
+ [Description("Retrieves the HTTP headers from the current request and returns them as a JSON object.")]
+ public object GetHttpHeaders()
+ {
+ var context = httpContextAccessor.HttpContext;
+ if (context == null)
+ {
+ return "No HTTP context available";
+ }
+
+ var headers = new Dictionary();
+ foreach (var header in context.Request.Headers)
+ {
+ headers[header.Key] = string.Join(", ", header.Value.ToArray());
+ }
+
+ return headers;
+ }
+//
+}
diff --git a/docs/concepts/httpcontext/samples/TryItOut.ipynb b/docs/concepts/httpcontext/samples/TryItOut.ipynb
new file mode 100644
index 000000000..95ebfafec
--- /dev/null
+++ b/docs/concepts/httpcontext/samples/TryItOut.ipynb
@@ -0,0 +1,112 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "37868ee2",
+ "metadata": {
+ "language_info": {
+ "name": "polyglot-notebook"
+ },
+ "polyglot_notebook": {
+ "kernelName": "csharp"
+ }
+ },
+ "source": [
+ "## HTTP Context\n",
+ "\n",
+ "This project illustrates how to access the HttpContext from tool calls. See the [README](../README.md) for more details.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "093a7d4f",
+ "metadata": {},
+ "source": [
+ "### Examples\n",
+ "\n",
+ "The following request illustrates a tool call that retrieves the HTTP headers from the [HttpContext] using the [IHttpContextAccessor].\n",
+ "\n",
+ "The server implements two other tools, `get_request_info` and `get_user_claims`. You can modify the code below to call these tools as well,\n",
+ "which illustrate how to access other parts of the [HttpContext].\n",
+ "\n",
+ "\n",
+ "[HttpContext]: https://docs.microsoft.com/dotnet/api/microsoft.aspnetcore.http.httpcontext\n",
+ "[IHttpContextAccessor]: https://docs.microsoft.com/dotnet/api/microsoft.aspnetcore.http.ihttpcontextaccessor"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "3324ec56",
+ "metadata": {
+ "language_info": {
+ "name": "polyglot-notebook"
+ },
+ "polyglot_notebook": {
+ "kernelName": "pwsh"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "{\n",
+ " \"result\": {\n",
+ " \"content\": [\n",
+ " {\n",
+ " \"type\": \"text\",\n",
+ " \"text\": \"[]\"\n",
+ " }\n",
+ " ],\n",
+ " \"structuredContent\": {\n",
+ " \"result\": []\n",
+ " }\n",
+ " },\n",
+ " \"id\": 1,\n",
+ " \"jsonrpc\": \"2.0\"\n",
+ "}\n"
+ ]
+ }
+ ],
+ "source": [
+ "curl -s -X POST http://localhost:3001 `\n",
+ "-H \"ProtocolVersion: 2025-06-18\" `\n",
+ "-H \"Accept: application/json, text/event-stream\" `\n",
+ "-H \"Content-Type: application/json\" `\n",
+ "-d '{\n",
+ " \"jsonrpc\": \"2.0\",\n",
+ " \"id\": 1,\n",
+ " \"method\": \"tools/call\",\n",
+ " \"params\": {\n",
+ " \"name\": \"get_user_claims\"\n",
+ " }\n",
+ "}' | Where-Object { $_ -like \"data:*\" } | ForEach-Object { $_.Substring(5) } | jq"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": ".NET (C#)",
+ "language": "C#",
+ "name": ".net-csharp"
+ },
+ "language_info": {
+ "name": "polyglot-notebook"
+ },
+ "polyglot_notebook": {
+ "kernelInfo": {
+ "defaultKernelName": "csharp",
+ "items": [
+ {
+ "aliases": [],
+ "languageName": "csharp",
+ "name": "csharp"
+ }
+ ]
+ }
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/samples/AspNetCoreSseServer/appsettings.Development.json b/docs/concepts/httpcontext/samples/appsettings.Development.json
similarity index 100%
rename from samples/AspNetCoreSseServer/appsettings.Development.json
rename to docs/concepts/httpcontext/samples/appsettings.Development.json
diff --git a/samples/AspNetCoreSseServer/appsettings.json b/docs/concepts/httpcontext/samples/appsettings.json
similarity index 100%
rename from samples/AspNetCoreSseServer/appsettings.json
rename to docs/concepts/httpcontext/samples/appsettings.json
diff --git a/docs/concepts/index.md b/docs/concepts/index.md
new file mode 100644
index 000000000..e038c8996
--- /dev/null
+++ b/docs/concepts/index.md
@@ -0,0 +1,2 @@
+
+Welcome to the conceptual documentation for the Model Context Protocol SDK. Here you'll find high-level overviews, explanations, and guides to help you understand how the SDK implements the Model Context Protocol.
diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md
new file mode 100644
index 000000000..411a61b1c
--- /dev/null
+++ b/docs/concepts/logging/logging.md
@@ -0,0 +1,101 @@
+---
+title: Logging
+author: mikekistler
+description: How to use the logging feature in the MCP C# SDK.
+uid: logging
+---
+
+## Logging
+
+MCP servers may expose log messages to clients through the [Logging utility].
+
+[Logging utility]: https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging
+
+This document describes how to implement logging in MCP servers and how clients can consume log messages.
+
+### Logging Levels
+
+MCP uses the logging levels defined in [RFC 5424](https://tools.ietf.org/html/rfc5424).
+
+The MCP C# SDK uses the standard .NET [ILogger] and [ILoggerProvider] abstractions, which support a slightly
+different set of logging levels. Here's the levels and how they map to standard .NET logging levels.
+
+| Level | .NET | Description | Example Use Case |
+|-----------|------|-----------------------------------|------------------------------|
+| debug | ✓ | Detailed debugging information | Function entry/exit points |
+| info | ✓ | General informational messages | Operation progress updates |
+| notice | | Normal but significant events | Configuration changes |
+| warning | ✓ | Warning conditions | Deprecated feature usage |
+| error | ✓ | Error conditions | Operation failures |
+| critical | ✓ | Critical conditions | System component failures |
+| alert | | Action must be taken immediately | Data corruption detected |
+| emergency | | System is unusable | |
+
+**Note:** .NET's [ILogger] also supports a `Trace` level (more verbose than Debug) log level.
+As there is no equivalent level in the MCP logging levels, Trace level logs messages are silently
+dropped when sending messages to the client.
+
+[ILogger]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.ilogger
+[ILoggerProvider]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.iloggerprovider
+
+### Server configuration and logging
+
+MCP servers that implement the Logging utility must declare this in the capabilities sent in the
+[Initialization] phase at the beginning of the MCP session.
+
+[Initialization]: https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization
+
+Servers built with the C# SDK always declare the logging capability. Doing so does not obligate the server
+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
+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
+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.
+
+[!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
+
+[!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
+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.
+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/logging/samples/client/LoggingClient.csproj b/docs/concepts/logging/samples/client/LoggingClient.csproj
new file mode 100644
index 000000000..9f020005d
--- /dev/null
+++ b/docs/concepts/logging/samples/client/LoggingClient.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
diff --git a/docs/concepts/logging/samples/client/Program.cs b/docs/concepts/logging/samples/client/Program.cs
new file mode 100644
index 000000000..29a15726a
--- /dev/null
+++ b/docs/concepts/logging/samples/client/Program.cs
@@ -0,0 +1,67 @@
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Client;
+using System.Text.Json;
+
+var endpoint = Environment.GetEnvironmentVariable("ENDPOINT") ?? "http://localhost:3001";
+
+var clientTransport = new HttpClientTransport(new()
+{
+ Endpoint = new Uri(endpoint),
+ TransportMode = HttpTransportMode.StreamableHttp,
+});
+
+await using var mcpClient = await McpClient.CreateAsync(clientTransport);
+
+//
+// Verify that the server supports logging
+if (mcpClient.ServerCapabilities.Logging is null)
+{
+ Console.WriteLine("Server does not support logging.");
+ return;
+}
+//
+
+// Get the first argument if it was specified
+var firstArgument = args.Length > 0 ? args[0] : null;
+
+if (firstArgument is not null)
+{
+ // Set the logging level to the value from the first argument
+ if (Enum.TryParse(firstArgument, true, out var loggingLevel))
+ {
+ //
+ await mcpClient.SetLoggingLevel(loggingLevel);
+ //
+ }
+ else
+ {
+ Console.WriteLine($"Invalid logging level: {firstArgument}");
+ // Print a list of valid logging levels
+ Console.WriteLine("Valid logging levels are:");
+ foreach (var level in Enum.GetValues())
+ {
+ Console.WriteLine($" - {level}");
+ }
+ }
+}
+
+//
+mcpClient.RegisterNotificationHandler(NotificationMethods.LoggingMessageNotification,
+ (notification, ct) =>
+ {
+ if (JsonSerializer.Deserialize(notification.Params) is { } ln)
+ {
+ Console.WriteLine($"[{ln.Level}] {ln.Logger} {ln.Data}");
+ }
+ else
+ {
+ Console.WriteLine($"Received unexpected logging notification: {notification.Params}");
+ }
+
+ return default;
+ });
+//
+
+// Now call the "logging_tool" tool
+await mcpClient.CallToolAsync("logging_tool");
+
diff --git a/docs/concepts/logging/samples/server/Logging.csproj b/docs/concepts/logging/samples/server/Logging.csproj
new file mode 100644
index 000000000..f4998aa12
--- /dev/null
+++ b/docs/concepts/logging/samples/server/Logging.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
diff --git a/docs/concepts/logging/samples/server/Logging.http b/docs/concepts/logging/samples/server/Logging.http
new file mode 100644
index 000000000..3f0f028b7
--- /dev/null
+++ b/docs/concepts/logging/samples/server/Logging.http
@@ -0,0 +1,40 @@
+@HostAddress = http://localhost:3001
+
+POST {{HostAddress}}/
+Accept: application/json, text/event-stream
+Content-Type: application/json
+
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "clientInfo": {
+ "name": "RestClient",
+ "version": "0.1.0"
+ },
+ "capabilities": {},
+ "protocolVersion": "2025-06-18"
+ }
+}
+
+###
+
+@SessionId = JCo3W4Q7KA_evyWoFE5qwA
+
+###
+
+POST {{HostAddress}}/
+Accept: application/json, text/event-stream
+Content-Type: application/json
+MCP-Protocol-Version: 2025-06-18
+Mcp-Session-Id: {{SessionId}}
+
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "logging_tool"
+ }
+}
\ No newline at end of file
diff --git a/docs/concepts/logging/samples/server/Program.cs b/docs/concepts/logging/samples/server/Program.cs
new file mode 100644
index 000000000..7de039e09
--- /dev/null
+++ b/docs/concepts/logging/samples/server/Program.cs
@@ -0,0 +1,20 @@
+using Logging.Tools;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddMcpServer()
+ .WithHttpTransport(options =>
+ options.IdleTimeout = Timeout.InfiniteTimeSpan // Never timeout
+ )
+ .WithTools();
+ // .WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
+
+var app = builder.Build();
+
+app.UseHttpsRedirection();
+
+app.MapMcp();
+
+app.Run();
diff --git a/docs/concepts/logging/samples/server/Properties/launchSettings.json b/docs/concepts/logging/samples/server/Properties/launchSettings.json
new file mode 100644
index 000000000..c09325b27
--- /dev/null
+++ b/docs/concepts/logging/samples/server/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7207;http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/docs/concepts/logging/samples/server/Tools/LoggingTools.cs b/docs/concepts/logging/samples/server/Tools/LoggingTools.cs
new file mode 100644
index 000000000..33fa3c040
--- /dev/null
+++ b/docs/concepts/logging/samples/server/Tools/LoggingTools.cs
@@ -0,0 +1,45 @@
+using System.ComponentModel;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Logging.Tools;
+
+[McpServerToolType]
+public class LoggingTools
+{
+ [McpServerTool, Description("Demonstrates a tool that produces log messages")]
+ public static async Task LoggingTool(
+ RequestContext context,
+ int duration = 10,
+ int steps = 10)
+ {
+ var progressToken = context.Params?.ProgressToken;
+ var stepDuration = duration / steps;
+
+ //
+ ILoggerProvider loggerProvider = context.Server.AsClientLoggerProvider();
+ ILogger logger = loggerProvider.CreateLogger("LoggingTools");
+ //
+
+ for (int i = 1; i <= steps; i++)
+ {
+ await Task.Delay(stepDuration * 1000);
+
+ try
+ {
+ logger.LogCritical("A critical log message");
+ logger.LogError("An error log message");
+ logger.LogWarning("A warning log message");
+ logger.LogInformation("An informational log message");
+ logger.LogDebug("A debug log message");
+ logger.LogTrace("A trace log message");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "An error occurred while logging messages");
+ }
+ }
+
+ return $"Long running tool completed. Duration: {duration} seconds. Steps: {steps}.";
+ }
+}
diff --git a/docs/concepts/logging/samples/server/appsettings.Development.json b/docs/concepts/logging/samples/server/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/docs/concepts/logging/samples/server/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/docs/concepts/logging/samples/server/appsettings.json b/docs/concepts/logging/samples/server/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/docs/concepts/logging/samples/server/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/docs/concepts/progress/progress.md b/docs/concepts/progress/progress.md
new file mode 100644
index 000000000..ccdf9f19c
--- /dev/null
+++ b/docs/concepts/progress/progress.md
@@ -0,0 +1,69 @@
+---
+title: Progress
+author: mikekistler
+description:
+uid: progress
+---
+
+## Progress
+
+The Model Context Protocol (MCP) supports [progress tracking] for long-running operations through notification messages.
+
+[progress tracking]: https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress
+
+Typically progress tracking is supported by server tools that perform operations that take a significant amount of time to complete, such as image generation or complex calculations.
+However, progress tracking is defined in the MCP specification as a general feature that can be implemented for any request that is handled by either a server or a client.
+This project illustrates the common case of a server tool that performs a long-running operation and sends progress updates to the client.
+
+### Server Implementation
+
+When processing a request, the server can use the [sendNotificationAsync] extension method of [IMcpServer] 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.
+
+[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:
+
+[!code-csharp[](samples/server/Tools/LongRunningTools.cs?name=snippet_SendProgress)]
+
+### Client Implementation
+
+Clients request progress updates by including a `progressToken` in the parameters of a request.
+Note that servers are not required to support progress tracking, so clients should not depend on receiving progress updates.
+
+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__
+
+```csharp
+mcpClient.RegisterNotificationHandler(NotificationMethods.ProgressNotification,
+ (notification, cancellationToken) =>
+ {
+ if (JsonSerializer.Deserialize(notification.Params) is { } pn &&
+ pn.ProgressToken == progressToken)
+ {
+ // progress.Report(pn.Progress);
+ Console.WriteLine($"Tool progress: {pn.Progress.Progress} of {pn.Progress.Total} - {pn.Progress.Message}");
+ }
+ return ValueTask.CompletedTask;
+ }).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.
+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/docs/concepts/progress/samples/client/Program.cs b/docs/concepts/progress/samples/client/Program.cs
new file mode 100644
index 000000000..2a5f589de
--- /dev/null
+++ b/docs/concepts/progress/samples/client/Program.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+
+var endpoint = Environment.GetEnvironmentVariable("ENDPOINT") ?? "http://localhost:3001";
+
+var clientTransport = new HttpClientTransport(new()
+{
+ Endpoint = new Uri(endpoint),
+ TransportMode = HttpTransportMode.StreamableHttp,
+});
+
+McpClientOptions options = new()
+{
+ ClientInfo = new()
+ {
+ Name = "ProgressClient",
+ Version = "1.0.0"
+ }
+};
+
+await using var mcpClient = await McpClient.CreateAsync(clientTransport, options);
+
+var tools = await mcpClient.ListToolsAsync();
+foreach (var tool in tools)
+{
+ Console.WriteLine($"Connected to server with tools: {tool.Name}");
+}
+
+Console.WriteLine($"Calling tool: {tools.First().Name}");
+
+//
+var progressHandler = new Progress(value =>
+{
+ Console.WriteLine($"Tool progress: {value.Progress} of {value.Total} - {value.Message}");
+});
+
+var result = await mcpClient.CallToolAsync(toolName: tools.First().Name, progress: progressHandler);
+//
+
+foreach (var block in result.Content)
+{
+ if (block is TextContentBlock textBlock)
+ {
+ Console.WriteLine(textBlock.Text);
+ }
+ else
+ {
+ Console.WriteLine($"Received unexpected result content of type {block.GetType()}");
+ }
+}
diff --git a/docs/concepts/progress/samples/client/ProgressClient.csproj b/docs/concepts/progress/samples/client/ProgressClient.csproj
new file mode 100644
index 000000000..9f020005d
--- /dev/null
+++ b/docs/concepts/progress/samples/client/ProgressClient.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
diff --git a/docs/concepts/progress/samples/server/Program.cs b/docs/concepts/progress/samples/server/Program.cs
new file mode 100644
index 000000000..7216b2fe1
--- /dev/null
+++ b/docs/concepts/progress/samples/server/Program.cs
@@ -0,0 +1,22 @@
+using Progress.Tools;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddMcpServer()
+ .WithHttpTransport()
+ .WithTools();
+
+builder.Logging.AddConsole(options =>
+{
+ options.LogToStandardErrorThreshold = LogLevel.Information;
+});
+
+var app = builder.Build();
+
+app.UseHttpsRedirection();
+
+app.MapMcp();
+
+app.Run();
diff --git a/docs/concepts/progress/samples/server/Progress.csproj b/docs/concepts/progress/samples/server/Progress.csproj
new file mode 100644
index 000000000..f4998aa12
--- /dev/null
+++ b/docs/concepts/progress/samples/server/Progress.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
diff --git a/docs/concepts/progress/samples/server/Progress.http b/docs/concepts/progress/samples/server/Progress.http
new file mode 100644
index 000000000..3b40db854
--- /dev/null
+++ b/docs/concepts/progress/samples/server/Progress.http
@@ -0,0 +1,18 @@
+@HostAddress = http://localhost:3001
+
+POST {{HostAddress}}/
+Accept: application/json, text/event-stream
+Content-Type: application/json
+MCP-Protocol-Version: 2025-06-18
+
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "_meta": {
+ "progressToken": "abc123"
+ },
+ "name": "long_running_tool"
+ }
+}
diff --git a/docs/concepts/progress/samples/server/Properties/launchSettings.json b/docs/concepts/progress/samples/server/Properties/launchSettings.json
new file mode 100644
index 000000000..f5b342d69
--- /dev/null
+++ b/docs/concepts/progress/samples/server/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7175;http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs b/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs
new file mode 100644
index 000000000..7fcd1244a
--- /dev/null
+++ b/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs
@@ -0,0 +1,44 @@
+using System.ComponentModel;
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Progress.Tools;
+
+[McpServerToolType]
+public class LongRunningTools
+{
+ [McpServerTool, Description("Demonstrates a long running tool with progress updates")]
+ public static async Task LongRunningTool(
+ McpServer server,
+ RequestContext context,
+ int duration = 10,
+ int steps = 5)
+ {
+ var progressToken = context.Params?.ProgressToken;
+ var stepDuration = duration / steps;
+
+ for (int i = 1; i <= steps; i++)
+ {
+ await Task.Delay(stepDuration * 1000);
+
+ //
+ if (progressToken is not null)
+ {
+ await server.SendNotificationAsync("notifications/progress", new ProgressNotificationParams
+ {
+ ProgressToken = progressToken.Value,
+ Progress = new ProgressNotificationValue
+ {
+ Progress = i,
+ Total = steps,
+ Message = $"Step {i} of {steps} completed.",
+ },
+ });
+ }
+ //
+ }
+
+ return $"Long running tool completed. Duration: {duration} seconds. Steps: {steps}.";
+ }
+}
\ No newline at end of file
diff --git a/docs/concepts/progress/samples/server/appsettings.Development.json b/docs/concepts/progress/samples/server/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/docs/concepts/progress/samples/server/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/docs/concepts/progress/samples/server/appsettings.json b/docs/concepts/progress/samples/server/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/docs/concepts/progress/samples/server/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml
new file mode 100644
index 000000000..97ffbd16e
--- /dev/null
+++ b/docs/concepts/toc.yml
@@ -0,0 +1,19 @@
+items:
+- name: Overview
+ href: index.md
+- name: Base Protocol
+ items:
+ - name: Progress
+ uid: progress
+- name: Client Features
+ items:
+ - name: Elicitation
+ uid: elicitation
+- name: Server Features
+ items:
+ - name: Logging
+ uid: logging
+ - name: HTTP Context
+ uid: httpcontext
+ - name: Filters
+ uid: filters
\ No newline at end of file
diff --git a/docs/docfx.json b/docs/docfx.json
index 6b4feb833..fe8a18d95 100644
--- a/docs/docfx.json
+++ b/docs/docfx.json
@@ -42,6 +42,7 @@
"_appLogoPath": "images/mcp.svg",
"_appFaviconPath": "images/favicon.ico",
"_enableSearch": true,
+ "_disableNextArticle": true,
"pdf": false
}
}
diff --git a/docs/toc.yml b/docs/toc.yml
index f63a01348..350a2ae3b 100644
--- a/docs/toc.yml
+++ b/docs/toc.yml
@@ -1,5 +1,7 @@
items:
-- name: API Docs
+- name: Documentation
+ href: concepts/index.md
+- name: API Reference
href: api/ModelContextProtocol.yml
- name: Github
href: https://github.com/ModelContextProtocol/csharp-sdk
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj b/samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj
new file mode 100644
index 000000000..23e95062c
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/Program.cs b/samples/AspNetCoreMcpPerSessionTools/Program.cs
new file mode 100644
index 000000000..3484978ec
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/Program.cs
@@ -0,0 +1,86 @@
+using AspNetCoreMcpPerSessionTools.Tools;
+using ModelContextProtocol.Server;
+using System.Collections.Concurrent;
+using System.Reflection;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Create and populate the tool dictionary at startup
+var toolDictionary = new ConcurrentDictionary();
+PopulateToolDictionary(toolDictionary);
+
+// Register all MCP server tools - they will be filtered per session based on route
+builder.Services.AddMcpServer()
+ .WithHttpTransport(options =>
+ {
+ // Configure per-session options to filter tools based on route category
+ options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) =>
+ {
+ // Determine tool category from route parameters
+ var toolCategory = httpContext.Request.RouteValues["toolCategory"]?.ToString()?.ToLower() ?? "all";
+
+ // Get pre-populated tools for the requested category
+ if (toolDictionary.TryGetValue(toolCategory, out var tools))
+ {
+ mcpOptions.Capabilities = new();
+ mcpOptions.Capabilities.Tools = new();
+ var toolCollection = mcpOptions.ToolCollection = new();
+
+ foreach (var tool in tools)
+ {
+ toolCollection.Add(tool);
+ }
+ }
+ };
+ });
+
+var app = builder.Build();
+
+// Map MCP with route parameter for tool category filtering
+app.MapMcp("/{toolCategory?}");
+
+app.Run();
+
+// Helper method to populate the tool dictionary at startup
+static void PopulateToolDictionary(ConcurrentDictionary toolDictionary)
+{
+ // Get tools for each category
+ var clockTools = GetToolsForType();
+ var calculatorTools = GetToolsForType();
+ var userInfoTools = GetToolsForType();
+ McpServerTool[] allTools = [.. clockTools,
+ .. calculatorTools,
+ .. userInfoTools];
+
+ // Populate the dictionary with tools for each category
+ toolDictionary.TryAdd("clock", clockTools);
+ toolDictionary.TryAdd("calculator", calculatorTools);
+ toolDictionary.TryAdd("userinfo", userInfoTools);
+ toolDictionary.TryAdd("all", allTools);
+}
+
+// Helper method to get tools for a specific type using reflection
+static McpServerTool[] GetToolsForType<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)] T>()
+{
+ var tools = new List();
+ var toolType = typeof(T);
+ var methods = toolType.GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .Where(m => m.GetCustomAttributes(typeof(McpServerToolAttribute), false).Any());
+
+ foreach (var method in methods)
+ {
+ try
+ {
+ var tool = McpServerTool.Create(method, target: null, new McpServerToolCreateOptions());
+ tools.Add(tool);
+ }
+ catch (Exception ex)
+ {
+ // Log error but continue with other tools
+ Console.WriteLine($"Failed to add tool {toolType.Name}.{method.Name}: {ex.Message}");
+ }
+ }
+
+ return [.. tools];
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/Properties/launchSettings.json b/samples/AspNetCoreMcpPerSessionTools/Properties/launchSettings.json
new file mode 100644
index 000000000..da8208a11
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/README.md b/samples/AspNetCoreMcpPerSessionTools/README.md
new file mode 100644
index 000000000..8e3665100
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/README.md
@@ -0,0 +1,113 @@
+# ASP.NET Core MCP Server with Per-Session Tool Filtering
+
+This sample demonstrates how to create an MCP (Model Context Protocol) server that provides different sets of tools based on route-based session configuration. This showcases the technique of using `ConfigureSessionOptions` to dynamically modify the `ToolCollection` based on route parameters for each MCP session.
+
+## Overview
+
+The sample demonstrates route-based tool filtering using the SDK's `ConfigureSessionOptions` callback. You could use any mechanism, routing is just one way to achieve this. The point of the sample is to show how an MCP server can dynamically adjust the available tools for each session based on arbitrary criteria, in this case, the URL route.
+
+## Route-Based Configuration
+
+The server uses route parameters to determine which tools to make available:
+
+- `GET /clock` - MCP server with only clock/time tools
+- `GET /calculator` - MCP server with only calculation tools
+- `GET /userinfo` - MCP server with only session/system info tools
+- `GET /all` or `GET /` - MCP server with all tools (default)
+
+## Running the Sample
+
+1. Navigate to the sample directory:
+ ```bash
+ cd samples/AspNetCoreMcpPerSessionTools
+ ```
+
+2. Run the server:
+ ```bash
+ dotnet run
+ ```
+
+3. The server will start on `https://localhost:5001` (or the port shown in the console)
+
+## Testing Tool Categories
+
+### Testing Clock Tools
+Connect your MCP client to: `https://localhost:5001/clock`
+- Available tools: GetTime, GetDate, ConvertTimeZone
+
+### Testing Calculator Tools
+Connect your MCP client to: `https://localhost:5001/calculator`
+- Available tools: Calculate, CalculatePercentage, SquareRoot
+
+### Testing UserInfo Tools
+Connect your MCP client to: `https://localhost:5001/userinfo`
+- Available tools: GetUserInfo
+
+### Testing All Tools
+Connect your MCP client to: `https://localhost:5001/all` or `https://localhost:5001/`
+- Available tools: All tools from all categories
+
+## How It Works
+
+### 1. Tool Registration
+All tools are registered during startup using the normal MCP tool registration:
+
+```csharp
+builder.Services.AddMcpServer()
+ .WithTools()
+ .WithTools()
+ .WithTools();
+```
+
+### 2. Route-Based Session Filtering
+The key technique is using `ConfigureSessionOptions` to modify the tool collection per session based on the route:
+
+```csharp
+.WithHttpTransport(options =>
+{
+ options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) =>
+ {
+ var toolCategory = GetToolCategoryFromRoute(httpContext);
+ var toolCollection = mcpOptions.Capabilities?.Tools?.ToolCollection;
+
+ if (toolCollection != null)
+ {
+ // Clear all tools and add back only those for this category
+ toolCollection.Clear();
+
+ switch (toolCategory?.ToLower())
+ {
+ case "clock":
+ AddToolsForType(toolCollection);
+ break;
+ case "calculator":
+ AddToolsForType(toolCollection);
+ break;
+ case "userinfo":
+ AddToolsForType(toolCollection);
+ break;
+ default:
+ // All tools for default/all category
+ AddToolsForType(toolCollection);
+ AddToolsForType(toolCollection);
+ AddToolsForType(toolCollection);
+ break;
+ }
+ }
+ };
+})
+```
+
+### 3. Route Parameter Detection
+The `GetToolCategoryFromRoute` method extracts the tool category from the URL route:
+
+```csharp
+static string? GetToolCategoryFromRoute(HttpContext context)
+{
+ if (context.Request.RouteValues.TryGetValue("toolCategory", out var categoryObj) && categoryObj is string category)
+ {
+ return string.IsNullOrEmpty(category) ? "all" : category;
+ }
+ return "all"; // Default
+}
+```
diff --git a/samples/AspNetCoreMcpPerSessionTools/Tools/CalculatorTool.cs b/samples/AspNetCoreMcpPerSessionTools/Tools/CalculatorTool.cs
new file mode 100644
index 000000000..c6d9f6216
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/Tools/CalculatorTool.cs
@@ -0,0 +1,81 @@
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+
+namespace AspNetCoreMcpPerSessionTools.Tools;
+
+///
+/// Calculator tools for mathematical operations
+///
+[McpServerToolType]
+public sealed class CalculatorTool
+{
+ [McpServerTool, Description("Performs basic arithmetic calculations (addition, subtraction, multiplication, division).")]
+ public static string Calculate([Description("Mathematical expression to evaluate (e.g., '5 + 3', '10 - 2', '4 * 6', '15 / 3')")] string expression)
+ {
+ try
+ {
+ // Simple calculator for demo purposes - supports basic operations
+ expression = expression.Trim();
+
+ if (expression.Contains("+"))
+ {
+ var parts = expression.Split('+');
+ if (parts.Length == 2 && double.TryParse(parts[0].Trim(), out var a) && double.TryParse(parts[1].Trim(), out var b))
+ {
+ return $"{expression} = {a + b}";
+ }
+ }
+ else if (expression.Contains("-"))
+ {
+ var parts = expression.Split('-');
+ if (parts.Length == 2 && double.TryParse(parts[0].Trim(), out var a) && double.TryParse(parts[1].Trim(), out var b))
+ {
+ return $"{expression} = {a - b}";
+ }
+ }
+ else if (expression.Contains("*"))
+ {
+ var parts = expression.Split('*');
+ if (parts.Length == 2 && double.TryParse(parts[0].Trim(), out var a) && double.TryParse(parts[1].Trim(), out var b))
+ {
+ return $"{expression} = {a * b}";
+ }
+ }
+ else if (expression.Contains("/"))
+ {
+ var parts = expression.Split('/');
+ if (parts.Length == 2 && double.TryParse(parts[0].Trim(), out var a) && double.TryParse(parts[1].Trim(), out var b))
+ {
+ if (b == 0)
+ return "Error: Division by zero";
+ return $"{expression} = {a / b}";
+ }
+ }
+
+ return $"Cannot evaluate expression: {expression}. Supported operations: +, -, *, / (e.g., '5 + 3')";
+ }
+ catch (Exception ex)
+ {
+ return $"Error evaluating '{expression}': {ex.Message}";
+ }
+ }
+
+ [McpServerTool, Description("Calculates percentage of a number.")]
+ public static string CalculatePercentage(
+ [Description("The number to calculate percentage of")] double number,
+ [Description("The percentage value")] double percentage)
+ {
+ var result = (number * percentage) / 100;
+ return $"{percentage}% of {number} = {result}";
+ }
+
+ [McpServerTool, Description("Calculates the square root of a number.")]
+ public static string SquareRoot([Description("The number to find square root of")] double number)
+ {
+ if (number < 0)
+ return "Error: Cannot calculate square root of negative number";
+
+ var result = Math.Sqrt(number);
+ return $"√{number} = {result}";
+ }
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/Tools/ClockTool.cs b/samples/AspNetCoreMcpPerSessionTools/Tools/ClockTool.cs
new file mode 100644
index 000000000..d112de0f9
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/Tools/ClockTool.cs
@@ -0,0 +1,40 @@
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+
+namespace AspNetCoreMcpPerSessionTools.Tools;
+
+///
+/// Clock-related tools for time and date operations
+///
+[McpServerToolType]
+public sealed class ClockTool
+{
+ [McpServerTool, Description("Gets the current server time in various formats.")]
+ public static string GetTime()
+ {
+ return $"Current server time: {DateTime.Now:yyyy-MM-dd HH:mm:ss} UTC";
+ }
+
+ [McpServerTool, Description("Gets the current date in a specific format.")]
+ public static string GetDate([Description("Date format (e.g., 'yyyy-MM-dd', 'MM/dd/yyyy')")] string format = "yyyy-MM-dd")
+ {
+ try
+ {
+ return $"Current date: {DateTime.Now.ToString(format)}";
+ }
+ catch (FormatException)
+ {
+ return $"Invalid format '{format}'. Using default: {DateTime.Now:yyyy-MM-dd}";
+ }
+ }
+
+ [McpServerTool, Description("Converts time between timezones.")]
+ public static string ConvertTimeZone(
+ [Description("Source timezone (e.g., 'UTC', 'EST')")] string fromTimeZone = "UTC",
+ [Description("Target timezone (e.g., 'PST', 'GMT')")] string toTimeZone = "PST")
+ {
+ // Simplified timezone conversion for demo purposes
+ var now = DateTime.Now;
+ return $"Time conversion from {fromTimeZone} to {toTimeZone}: {now:HH:mm:ss} (simulated)";
+ }
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/Tools/UserInfoTool.cs b/samples/AspNetCoreMcpPerSessionTools/Tools/UserInfoTool.cs
new file mode 100644
index 000000000..1fec18733
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/Tools/UserInfoTool.cs
@@ -0,0 +1,23 @@
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+
+namespace AspNetCoreMcpPerSessionTools.Tools;
+
+///
+/// User information tools
+///
+[McpServerToolType]
+public sealed class UserInfoTool
+{
+ [McpServerTool, Description("Gets information about the current user in the MCP session.")]
+ public static string GetUserInfo()
+ {
+ // Dummy user information for demonstration purposes
+ return $"User Information:\n" +
+ $"- User ID: {Guid.NewGuid():N}[..8] (simulated)\n" +
+ $"- Username: User{new Random().Next(1, 1000)}\n" +
+ $"- Roles: User, Guest\n" +
+ $"- Last Login: {DateTime.Now.AddMinutes(-new Random().Next(1, 60)):HH:mm:ss}\n" +
+ $"- Account Status: Active";
+ }
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/appsettings.Development.json b/samples/AspNetCoreMcpPerSessionTools/appsettings.Development.json
new file mode 100644
index 000000000..f999bc20e
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMcpPerSessionTools/appsettings.json b/samples/AspNetCoreMcpPerSessionTools/appsettings.json
new file mode 100644
index 000000000..88c89fa7d
--- /dev/null
+++ b/samples/AspNetCoreMcpPerSessionTools/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "AspNetCoreMcpPerSessionTools": "Debug"
+ }
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj
similarity index 100%
rename from samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj
rename to samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj
diff --git a/samples/AspNetCoreSseServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs
similarity index 61%
rename from samples/AspNetCoreSseServer/Program.cs
rename to samples/AspNetCoreMcpServer/Program.cs
index c21b328f6..96f89bffa 100644
--- a/samples/AspNetCoreSseServer/Program.cs
+++ b/samples/AspNetCoreMcpServer/Program.cs
@@ -1,14 +1,16 @@
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
-using TestServerWithHosting.Tools;
-using TestServerWithHosting.Resources;
+using AspNetCoreMcpServer.Tools;
+using AspNetCoreMcpServer.Resources;
+using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools()
.WithTools()
+ .WithTools()
.WithResources();
builder.Services.AddOpenTelemetry()
@@ -21,6 +23,13 @@
.WithLogging()
.UseOtlpExporter();
+// Configure HttpClientFactory for weather.gov API
+builder.Services.AddHttpClient("WeatherApi", client =>
+{
+ client.BaseAddress = new Uri("https://api.weather.gov");
+ client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
+});
+
var app = builder.Build();
app.MapMcp();
diff --git a/samples/AspNetCoreMcpServer/Properties/launchSettings.json b/samples/AspNetCoreMcpServer/Properties/launchSettings.json
new file mode 100644
index 000000000..6670029e1
--- /dev/null
+++ b/samples/AspNetCoreMcpServer/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "applicationUrl": "http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "OTEL_SERVICE_NAME": "aspnetcore-mcp-server"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "applicationUrl": "https://localhost:7133;http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "OTEL_SERVICE_NAME": "aspnetcore-mcp-server"
+ }
+ }
+ }
+}
diff --git a/samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs b/samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs
similarity index 76%
rename from samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs
rename to samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs
index e73ce133c..aaf6d11a5 100644
--- a/samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs
+++ b/samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs
@@ -1,8 +1,7 @@
-using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
-namespace TestServerWithHosting.Resources;
+namespace AspNetCoreMcpServer.Resources;
[McpServerResourceType]
public class SimpleResourceType
diff --git a/samples/AspNetCoreSseServer/Tools/EchoTool.cs b/samples/AspNetCoreMcpServer/Tools/EchoTool.cs
similarity index 88%
rename from samples/AspNetCoreSseServer/Tools/EchoTool.cs
rename to samples/AspNetCoreMcpServer/Tools/EchoTool.cs
index 7913b73e4..a9dc0a665 100644
--- a/samples/AspNetCoreSseServer/Tools/EchoTool.cs
+++ b/samples/AspNetCoreMcpServer/Tools/EchoTool.cs
@@ -1,7 +1,7 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
-namespace TestServerWithHosting.Tools;
+namespace AspNetCoreMcpServer.Tools;
[McpServerToolType]
public sealed class EchoTool
diff --git a/samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs b/samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs
similarity index 93%
rename from samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs
rename to samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs
index 247619dbb..e69477452 100644
--- a/samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs
+++ b/samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs
@@ -2,7 +2,7 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
-namespace TestServerWithHosting.Tools;
+namespace AspNetCoreMcpServer.Tools;
///
/// This tool uses dependency injection and async method
@@ -12,7 +12,7 @@ public sealed class SampleLlmTool
{
[McpServerTool(Name = "sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
public static async Task SampleLLM(
- IMcpServer thisServer,
+ McpServer thisServer,
[Description("The prompt to send to the LLM")] string prompt,
[Description("Maximum number of tokens to generate")] int maxTokens,
CancellationToken cancellationToken)
diff --git a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs
new file mode 100644
index 000000000..b4e3a7414
--- /dev/null
+++ b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs
@@ -0,0 +1,73 @@
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+using System.Globalization;
+using System.Text.Json;
+
+namespace AspNetCoreMcpServer.Tools;
+
+[McpServerToolType]
+public sealed class WeatherTools
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public WeatherTools(IHttpClientFactory httpClientFactory)
+ {
+ _httpClientFactory = httpClientFactory;
+ }
+
+ [McpServerTool, Description("Get weather alerts for a US state.")]
+ public async Task GetAlerts(
+ [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)
+ {
+ var client = _httpClientFactory.CreateClient("WeatherApi");
+ using var responseStream = await client.GetStreamAsync($"/alerts/active/area/{state}");
+ using var jsonDocument = await JsonDocument.ParseAsync(responseStream)
+ ?? throw new McpException("No JSON returned from alerts endpoint");
+
+ var alerts = jsonDocument.RootElement.GetProperty("features").EnumerateArray();
+
+ if (!alerts.Any())
+ {
+ return "No active alerts for this state.";
+ }
+
+ return string.Join("\n--\n", alerts.Select(alert =>
+ {
+ JsonElement properties = alert.GetProperty("properties");
+ return $"""
+ Event: {properties.GetProperty("event").GetString()}
+ Area: {properties.GetProperty("areaDesc").GetString()}
+ Severity: {properties.GetProperty("severity").GetString()}
+ Description: {properties.GetProperty("description").GetString()}
+ Instruction: {properties.GetProperty("instruction").GetString()}
+ """;
+ }));
+ }
+
+ [McpServerTool, Description("Get weather forecast for a location.")]
+ public async Task GetForecast(
+ [Description("Latitude of the location.")] double latitude,
+ [Description("Longitude of the location.")] double longitude)
+ {
+ var client = _httpClientFactory.CreateClient("WeatherApi");
+ var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
+
+ using var locationResponseStream = await client.GetStreamAsync(pointUrl);
+ using var locationDocument = await JsonDocument.ParseAsync(locationResponseStream);
+ var forecastUrl = locationDocument?.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
+ ?? throw new McpException($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
+
+ using var forecastResponseStream = await client.GetStreamAsync(forecastUrl);
+ using var forecastDocument = await JsonDocument.ParseAsync(forecastResponseStream);
+ var periods = forecastDocument?.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray()
+ ?? throw new McpException("No JSON returned from forecast endpoint");
+
+ return string.Join("\n---\n", periods.Select(period => $"""
+ {period.GetProperty("name").GetString()}
+ Temperature: {period.GetProperty("temperature").GetInt32()}°F
+ Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()}
+ Forecast: {period.GetProperty("detailedForecast").GetString()}
+ """));
+ }
+}
diff --git a/samples/AspNetCoreMcpServer/appsettings.Development.json b/samples/AspNetCoreMcpServer/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/samples/AspNetCoreMcpServer/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/samples/AspNetCoreMcpServer/appsettings.json b/samples/AspNetCoreMcpServer/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/samples/AspNetCoreMcpServer/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/samples/ChatWithTools/Program.cs b/samples/ChatWithTools/Program.cs
index ba597ae8a..c6fca0493 100644
--- a/samples/ChatWithTools/Program.cs
+++ b/samples/ChatWithTools/Program.cs
@@ -32,7 +32,7 @@
.UseOpenTelemetry(loggerFactory: loggerFactory, configure: o => o.EnableSensitiveData = true)
.Build();
-var mcpClient = await McpClientFactory.CreateAsync(
+var mcpClient = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Command = "npx",
@@ -41,7 +41,10 @@
}),
clientOptions: new()
{
- Capabilities = new() { Sampling = new() { SamplingHandler = samplingClient.CreateSamplingHandler() } },
+ Handlers = new()
+ {
+ SamplingHandler = samplingClient.CreateSamplingHandler()
+ }
},
loggerFactory: loggerFactory);
diff --git a/samples/EverythingServer/LoggingUpdateMessageSender.cs b/samples/EverythingServer/LoggingUpdateMessageSender.cs
index 844aa70d8..5f524ad8a 100644
--- a/samples/EverythingServer/LoggingUpdateMessageSender.cs
+++ b/samples/EverythingServer/LoggingUpdateMessageSender.cs
@@ -5,7 +5,7 @@
namespace EverythingServer;
-public class LoggingUpdateMessageSender(IMcpServer server, Func getMinLevel) : BackgroundService
+public class LoggingUpdateMessageSender(McpServer server, Func getMinLevel) : BackgroundService
{
readonly Dictionary _loggingLevelMap = new()
{
diff --git a/samples/EverythingServer/SubscriptionMessageSender.cs b/samples/EverythingServer/SubscriptionMessageSender.cs
index 774d98523..b071965dc 100644
--- a/samples/EverythingServer/SubscriptionMessageSender.cs
+++ b/samples/EverythingServer/SubscriptionMessageSender.cs
@@ -2,7 +2,7 @@
using ModelContextProtocol;
using ModelContextProtocol.Server;
-internal class SubscriptionMessageSender(IMcpServer server, HashSet subscriptions) : BackgroundService
+internal class SubscriptionMessageSender(McpServer server, HashSet subscriptions) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
diff --git a/samples/EverythingServer/Tools/LongRunningTool.cs b/samples/EverythingServer/Tools/LongRunningTool.cs
index 27f6ac20f..405b5e823 100644
--- a/samples/EverythingServer/Tools/LongRunningTool.cs
+++ b/samples/EverythingServer/Tools/LongRunningTool.cs
@@ -10,7 +10,7 @@ public class LongRunningTool
{
[McpServerTool(Name = "longRunningOperation"), Description("Demonstrates a long running operation with progress updates")]
public static async Task LongRunningOperation(
- IMcpServer server,
+ McpServer server,
RequestContext context,
int duration = 10,
int steps = 5)
diff --git a/samples/EverythingServer/Tools/SampleLlmTool.cs b/samples/EverythingServer/Tools/SampleLlmTool.cs
index a58675c30..6bbe6e51d 100644
--- a/samples/EverythingServer/Tools/SampleLlmTool.cs
+++ b/samples/EverythingServer/Tools/SampleLlmTool.cs
@@ -9,7 +9,7 @@ public class SampleLlmTool
{
[McpServerTool(Name = "sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
public static async Task SampleLLM(
- IMcpServer server,
+ McpServer server,
[Description("The prompt to send to the LLM")] string prompt,
[Description("Maximum number of tokens to generate")] int maxTokens,
CancellationToken cancellationToken)
diff --git a/samples/InMemoryTransport/InMemoryTransport.csproj b/samples/InMemoryTransport/InMemoryTransport.csproj
new file mode 100644
index 000000000..7c1161ce9
--- /dev/null
+++ b/samples/InMemoryTransport/InMemoryTransport.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/samples/InMemoryTransport/Program.cs b/samples/InMemoryTransport/Program.cs
new file mode 100644
index 000000000..dbffaa34d
--- /dev/null
+++ b/samples/InMemoryTransport/Program.cs
@@ -0,0 +1,34 @@
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.IO.Pipelines;
+
+Pipe clientToServerPipe = new(), serverToClientPipe = new();
+
+// Create a server using a stream-based transport over an in-memory pipe.
+await using McpServer server = McpServer.Create(
+ new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()),
+ new McpServerOptions()
+ {
+ ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })]
+ });
+_ = server.RunAsync();
+
+// Connect a client using a stream-based transport over the same in-memory pipe.
+await using McpClient client = await McpClient.CreateAsync(
+ new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream()));
+
+// List all tools.
+var tools = await client.ListToolsAsync();
+foreach (var tool in tools)
+{
+ Console.WriteLine($"Tool Name: {tool.Name}");
+}
+Console.WriteLine();
+
+// Invoke a tool.
+var echo = tools.First(t => t.Name == "Echo");
+Console.WriteLine(await echo.InvokeAsync(new()
+{
+ ["arg"] = "Hello World"
+}));
\ No newline at end of file
diff --git a/samples/ProtectedMCPServer/Tools/HttpClientExt.cs b/samples/ProtectedMCPServer/Tools/HttpClientExt.cs
deleted file mode 100644
index f7b2b5499..000000000
--- a/samples/ProtectedMCPServer/Tools/HttpClientExt.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using System.Text.Json;
-
-namespace ModelContextProtocol;
-
-internal static class HttpClientExt
-{
- public static async Task ReadJsonDocumentAsync(this HttpClient client, string requestUri)
- {
- using var response = await client.GetAsync(requestUri);
- response.EnsureSuccessStatusCode();
- return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
- }
-}
\ No newline at end of file
diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMcpClient/Program.cs
similarity index 94%
rename from samples/ProtectedMCPClient/Program.cs
rename to samples/ProtectedMcpClient/Program.cs
index 516227b37..9dc2410ea 100644
--- a/samples/ProtectedMCPClient/Program.cs
+++ b/samples/ProtectedMcpClient/Program.cs
@@ -25,19 +25,22 @@
builder.AddConsole();
});
-var transport = new SseClientTransport(new()
+var transport = new HttpClientTransport(new()
{
Endpoint = new Uri(serverUrl),
Name = "Secure Weather Client",
OAuth = new()
{
- ClientName = "ProtectedMcpClient",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ DynamicClientRegistration = new()
+ {
+ ClientName = "ProtectedMcpClient",
+ },
}
}, httpClient, consoleLoggerFactory);
-var client = await McpClientFactory.CreateAsync(transport, loggerFactory: consoleLoggerFactory);
+var client = await McpClient.CreateAsync(transport, loggerFactory: consoleLoggerFactory);
var tools = await client.ListToolsAsync();
if (tools.Count == 0)
diff --git a/samples/ProtectedMCPClient/ProtectedMCPClient.csproj b/samples/ProtectedMcpClient/ProtectedMcpClient.csproj
similarity index 100%
rename from samples/ProtectedMCPClient/ProtectedMCPClient.csproj
rename to samples/ProtectedMcpClient/ProtectedMcpClient.csproj
diff --git a/samples/ProtectedMCPClient/README.md b/samples/ProtectedMcpClient/README.md
similarity index 92%
rename from samples/ProtectedMCPClient/README.md
rename to samples/ProtectedMcpClient/README.md
index 977331a04..81ae67cee 100644
--- a/samples/ProtectedMCPClient/README.md
+++ b/samples/ProtectedMcpClient/README.md
@@ -14,7 +14,7 @@ The Protected MCP Client sample shows how to:
- .NET 9.0 or later
- A running TestOAuthServer (for OAuth authentication)
-- A running ProtectedMCPServer (for MCP services)
+- A running ProtectedMcpServer (for MCP services)
## Setup and Running
@@ -31,10 +31,10 @@ The OAuth server will start at `https://localhost:7029`
### Step 2: Start the Protected MCP Server
-Next, start the ProtectedMCPServer which provides the weather tools:
+Next, start the ProtectedMcpServer which provides the weather tools:
```bash
-cd samples\ProtectedMCPServer
+cd samples\ProtectedMcpServer
dotnet run
```
@@ -45,7 +45,7 @@ The protected server will start at `http://localhost:7071`
Finally, run this client:
```bash
-cd samples\ProtectedMCPClient
+cd samples\ProtectedMcpClient
dotnet run
```
@@ -90,4 +90,4 @@ Once authenticated, the client can access weather tools including:
## Key Files
- `Program.cs`: Main client application with OAuth flow implementation
-- `ProtectedMCPClient.csproj`: Project file with dependencies
\ No newline at end of file
+- `ProtectedMcpClient.csproj`: Project file with dependencies
\ No newline at end of file
diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMcpServer/Program.cs
similarity index 99%
rename from samples/ProtectedMCPServer/Program.cs
rename to samples/ProtectedMcpServer/Program.cs
index ef70fe731..a36e0367f 100644
--- a/samples/ProtectedMCPServer/Program.cs
+++ b/samples/ProtectedMcpServer/Program.cs
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore.Authentication;
-using ProtectedMCPServer.Tools;
+using ProtectedMcpServer.Tools;
using System.Net.Http.Headers;
using System.Security.Claims;
diff --git a/samples/ProtectedMCPServer/Properties/launchSettings.json b/samples/ProtectedMcpServer/Properties/launchSettings.json
similarity index 89%
rename from samples/ProtectedMCPServer/Properties/launchSettings.json
rename to samples/ProtectedMcpServer/Properties/launchSettings.json
index 31b04db83..dbc9a1147 100644
--- a/samples/ProtectedMCPServer/Properties/launchSettings.json
+++ b/samples/ProtectedMcpServer/Properties/launchSettings.json
@@ -1,6 +1,6 @@
{
"profiles": {
- "ProtectedMCPServer": {
+ "ProtectedMcpServer": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
diff --git a/samples/ProtectedMCPServer/ProtectedMCPServer.csproj b/samples/ProtectedMcpServer/ProtectedMcpServer.csproj
similarity index 100%
rename from samples/ProtectedMCPServer/ProtectedMCPServer.csproj
rename to samples/ProtectedMcpServer/ProtectedMcpServer.csproj
diff --git a/samples/ProtectedMCPServer/README.md b/samples/ProtectedMcpServer/README.md
similarity index 96%
rename from samples/ProtectedMCPServer/README.md
rename to samples/ProtectedMcpServer/README.md
index f0ac708a0..ecbfee633 100644
--- a/samples/ProtectedMCPServer/README.md
+++ b/samples/ProtectedMcpServer/README.md
@@ -34,7 +34,7 @@ The OAuth server will start at `https://localhost:7029`
Run this protected server:
```bash
-cd samples\ProtectedMCPServer
+cd samples\ProtectedMcpServer
dotnet run
```
@@ -42,10 +42,10 @@ The protected server will start at `http://localhost:7071`
### Step 3: Test with Protected MCP Client
-You can test the server using the ProtectedMCPClient sample:
+You can test the server using the ProtectedMcpClient sample:
```bash
-cd samples\ProtectedMCPClient
+cd samples\ProtectedMcpClient
dotnet run
```
diff --git a/samples/ProtectedMCPServer/Tools/WeatherTools.cs b/samples/ProtectedMcpServer/Tools/WeatherTools.cs
similarity index 70%
rename from samples/ProtectedMCPServer/Tools/WeatherTools.cs
rename to samples/ProtectedMcpServer/Tools/WeatherTools.cs
index 7c8c08514..94cc03892 100644
--- a/samples/ProtectedMCPServer/Tools/WeatherTools.cs
+++ b/samples/ProtectedMcpServer/Tools/WeatherTools.cs
@@ -4,7 +4,7 @@
using System.Globalization;
using System.Text.Json;
-namespace ProtectedMCPServer.Tools;
+namespace ProtectedMcpServer.Tools;
[McpServerToolType]
public sealed class WeatherTools
@@ -21,9 +21,10 @@ public async Task GetAlerts(
[Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)
{
var client = _httpClientFactory.CreateClient("WeatherApi");
- using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}");
- var jsonElement = jsonDocument.RootElement;
- var alerts = jsonElement.GetProperty("features").EnumerateArray();
+ using var jsonDocument = await client.GetFromJsonAsync($"/alerts/active/area/{state}")
+ ?? throw new McpException("No JSON returned from alerts endpoint");
+
+ var alerts = jsonDocument.RootElement.GetProperty("features").EnumerateArray();
if (!alerts.Any())
{
@@ -50,12 +51,14 @@ public async Task GetForecast(
{
var client = _httpClientFactory.CreateClient("WeatherApi");
var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
- using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl);
- var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
- ?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
- using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl);
- var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray();
+ using var locationDocument = await client.GetFromJsonAsync(pointUrl);
+ var forecastUrl = locationDocument?.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
+ ?? throw new McpException($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
+
+ using var forecastDocument = await client.GetFromJsonAsync(forecastUrl);
+ var periods = forecastDocument?.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray()
+ ?? throw new McpException("No JSON returned from forecast endpoint");
return string.Join("\n---\n", periods.Select(period => $"""
{period.GetProperty("name").GetString()}
diff --git a/samples/QuickstartClient/Program.cs b/samples/QuickstartClient/Program.cs
index 423af627f..cd1c4c60a 100644
--- a/samples/QuickstartClient/Program.cs
+++ b/samples/QuickstartClient/Program.cs
@@ -5,6 +5,7 @@
using ModelContextProtocol.Client;
using System.Diagnostics;
using System.Runtime.CompilerServices;
+using System.Text;
var builder = Host.CreateApplicationBuilder(args);
@@ -12,16 +13,27 @@
.AddEnvironmentVariables()
.AddUserSecrets();
+IClientTransport clientTransport;
var (command, arguments) = GetCommandAndArguments(args);
-var clientTransport = new StdioClientTransport(new()
+if (command == "http")
{
- Name = "Demo Server",
- Command = command,
- Arguments = arguments,
-});
-
-await using var mcpClient = await McpClientFactory.CreateAsync(clientTransport);
+ // make sure AspNetCoreMcpServer is running
+ clientTransport = new HttpClientTransport(new()
+ {
+ Endpoint = new Uri("http://localhost:3001")
+ });
+}
+else
+{
+ clientTransport = new StdioClientTransport(new()
+ {
+ Name = "Demo Server",
+ Command = command,
+ Arguments = arguments,
+ });
+}
+await using var mcpClient = await McpClient.CreateAsync(clientTransport!);
var tools = await mcpClient.ListToolsAsync();
foreach (var tool in tools)
@@ -46,8 +58,11 @@
Console.WriteLine("MCP Client Started!");
Console.ResetColor();
+var messages = new List();
+var sb = new StringBuilder();
+
PromptForInput();
-while(Console.ReadLine() is string query && !"exit".Equals(query, StringComparison.OrdinalIgnoreCase))
+while (Console.ReadLine() is string query && !"exit".Equals(query, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(query))
{
@@ -55,11 +70,17 @@
continue;
}
- await foreach (var message in anthropicClient.GetStreamingResponseAsync(query, options))
+ messages.Add(new ChatMessage(ChatRole.User, query));
+ await foreach (var message in anthropicClient.GetStreamingResponseAsync(messages, options))
{
Console.Write(message);
+ sb.Append(message.ToString());
}
+
Console.WriteLine();
+ sb.AppendLine();
+ messages.Add(new ChatMessage(ChatRole.Assistant, sb.ToString()));
+ sb.Clear();
PromptForInput();
}
@@ -79,15 +100,16 @@ static void PromptForInput()
///
/// This method uses the file extension of the first argument to determine the command, if it's py, it'll run python,
/// if it's js, it'll run node, if it's a directory or a csproj file, it'll run dotnet.
-///
+///
/// If no arguments are provided, it defaults to running the QuickstartWeatherServer project from the current repo.
-///
+///
/// This method would only be required if you're creating a generic client, such as we use for the quickstart.
///
static (string command, string[] arguments) GetCommandAndArguments(string[] args)
{
return args switch
{
+ [var mode] when mode.Equals("http", StringComparison.OrdinalIgnoreCase) => ("http", args),
[var script] when script.EndsWith(".py") => ("python", args),
[var script] when script.EndsWith(".js") => ("node", args),
[var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", ["run", "--project", script]),
diff --git a/samples/QuickstartWeatherServer/Program.cs b/samples/QuickstartWeatherServer/Program.cs
index 4e6216ee4..9bc050b54 100644
--- a/samples/QuickstartWeatherServer/Program.cs
+++ b/samples/QuickstartWeatherServer/Program.cs
@@ -15,11 +15,8 @@
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
-builder.Services.AddSingleton(_ =>
-{
- var client = new HttpClient { BaseAddress = new Uri("https://api.weather.gov") };
- client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
- return client;
-});
+using var httpClient = new HttpClient { BaseAddress = new Uri("https://api.weather.gov") };
+httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
+builder.Services.AddSingleton(httpClient);
await builder.Build().RunAsync();
diff --git a/samples/QuickstartWeatherServer/Tools/WeatherTools.cs b/samples/QuickstartWeatherServer/Tools/WeatherTools.cs
index e02d4c327..61dc0a0ee 100644
--- a/samples/QuickstartWeatherServer/Tools/WeatherTools.cs
+++ b/samples/QuickstartWeatherServer/Tools/WeatherTools.cs
@@ -43,9 +43,9 @@ public static async Task GetForecast(
[Description("Longitude of the location.")] double longitude)
{
var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
- using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl);
- var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
- ?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
+ using var locationDocument = await client.ReadJsonDocumentAsync(pointUrl);
+ var forecastUrl = locationDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
+ ?? throw new McpException($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl);
var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray();
diff --git a/samples/TestServerWithHosting/Tools/SampleLlmTool.cs b/samples/TestServerWithHosting/Tools/SampleLlmTool.cs
index a096f9301..2c96b8c35 100644
--- a/samples/TestServerWithHosting/Tools/SampleLlmTool.cs
+++ b/samples/TestServerWithHosting/Tools/SampleLlmTool.cs
@@ -12,7 +12,7 @@ public sealed class SampleLlmTool
{
[McpServerTool(Name = "sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
public static async Task SampleLLM(
- IMcpServer thisServer,
+ McpServer thisServer,
[Description("The prompt to send to the LLM")] string prompt,
[Description("Maximum number of tokens to generate")] int maxTokens,
CancellationToken cancellationToken)
diff --git a/src/Common/CancellableStreamReader/ValueStringBuilder.cs b/src/Common/CancellableStreamReader/ValueStringBuilder.cs
index 27bea693e..9f1dedcd5 100644
--- a/src/Common/CancellableStreamReader/ValueStringBuilder.cs
+++ b/src/Common/CancellableStreamReader/ValueStringBuilder.cs
@@ -8,310 +8,309 @@
#nullable enable
-namespace System.Text
+namespace System.Text;
+
+internal ref partial struct ValueStringBuilder
{
- internal ref partial struct ValueStringBuilder
+ private char[]? _arrayToReturnToPool;
+ private Span _chars;
+ private int _pos;
+
+ public ValueStringBuilder(Span initialBuffer)
{
- private char[]? _arrayToReturnToPool;
- private Span _chars;
- private int _pos;
+ _arrayToReturnToPool = null;
+ _chars = initialBuffer;
+ _pos = 0;
+ }
- public ValueStringBuilder(Span initialBuffer)
+ public ValueStringBuilder(int initialCapacity)
+ {
+ _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity);
+ _chars = _arrayToReturnToPool;
+ _pos = 0;
+ }
+
+ public int Length
+ {
+ get => _pos;
+ set
{
- _arrayToReturnToPool = null;
- _chars = initialBuffer;
- _pos = 0;
+ Debug.Assert(value >= 0);
+ Debug.Assert(value <= _chars.Length);
+ _pos = value;
}
+ }
+
+ public int Capacity => _chars.Length;
- public ValueStringBuilder(int initialCapacity)
+ public void EnsureCapacity(int capacity)
+ {
+ // This is not expected to be called this with negative capacity
+ Debug.Assert(capacity >= 0);
+
+ // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception.
+ if ((uint)capacity > (uint)_chars.Length)
+ Grow(capacity - _pos);
+ }
+
+ ///
+ /// Get a pinnable reference to the builder.
+ /// Does not ensure there is a null char after
+ /// This overload is pattern matched in the C# 7.3+ compiler so you can omit
+ /// the explicit method call, and write eg "fixed (char* c = builder)"
+ ///
+ public ref char GetPinnableReference()
+ {
+ return ref MemoryMarshal.GetReference(_chars);
+ }
+
+ ///
+ /// Get a pinnable reference to the builder.
+ ///
+ /// Ensures that the builder has a null char after
+ public ref char GetPinnableReference(bool terminate)
+ {
+ if (terminate)
{
- _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity);
- _chars = _arrayToReturnToPool;
- _pos = 0;
+ EnsureCapacity(Length + 1);
+ _chars[Length] = '\0';
}
+ return ref MemoryMarshal.GetReference(_chars);
+ }
- public int Length
+ public ref char this[int index]
+ {
+ get
{
- get => _pos;
- set
- {
- Debug.Assert(value >= 0);
- Debug.Assert(value <= _chars.Length);
- _pos = value;
- }
+ Debug.Assert(index < _pos);
+ return ref _chars[index];
}
+ }
- public int Capacity => _chars.Length;
+ public override string ToString()
+ {
+ string s = _chars.Slice(0, _pos).ToString();
+ Dispose();
+ return s;
+ }
- public void EnsureCapacity(int capacity)
- {
- // This is not expected to be called this with negative capacity
- Debug.Assert(capacity >= 0);
+ /// Returns the underlying storage of the builder.
+ public Span RawChars => _chars;
- // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception.
- if ((uint)capacity > (uint)_chars.Length)
- Grow(capacity - _pos);
+ ///
+ /// Returns a span around the contents of the builder.
+ ///
+ /// Ensures that the builder has a null char after
+ public ReadOnlySpan AsSpan(bool terminate)
+ {
+ if (terminate)
+ {
+ EnsureCapacity(Length + 1);
+ _chars[Length] = '\0';
}
+ return _chars.Slice(0, _pos);
+ }
+
+ public ReadOnlySpan AsSpan() => _chars.Slice(0, _pos);
+ public ReadOnlySpan AsSpan(int start) => _chars.Slice(start, _pos - start);
+ public ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length);
- ///
- /// Get a pinnable reference to the builder.
- /// Does not ensure there is a null char after
- /// This overload is pattern matched in the C# 7.3+ compiler so you can omit
- /// the explicit method call, and write eg "fixed (char* c = builder)"
- ///
- public ref char GetPinnableReference()
+ public bool TryCopyTo(Span destination, out int charsWritten)
+ {
+ if (_chars.Slice(0, _pos).TryCopyTo(destination))
{
- return ref MemoryMarshal.GetReference(_chars);
+ charsWritten = _pos;
+ Dispose();
+ return true;
}
-
- ///
- /// Get a pinnable reference to the builder.
- ///
- /// Ensures that the builder has a null char after
- public ref char GetPinnableReference(bool terminate)
+ else
{
- if (terminate)
- {
- EnsureCapacity(Length + 1);
- _chars[Length] = '\0';
- }
- return ref MemoryMarshal.GetReference(_chars);
+ charsWritten = 0;
+ Dispose();
+ return false;
}
+ }
- public ref char this[int index]
+ public void Insert(int index, char value, int count)
+ {
+ if (_pos > _chars.Length - count)
{
- get
- {
- Debug.Assert(index < _pos);
- return ref _chars[index];
- }
+ Grow(count);
}
- public override string ToString()
+ int remaining = _pos - index;
+ _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
+ _chars.Slice(index, count).Fill(value);
+ _pos += count;
+ }
+
+ public void Insert(int index, string? s)
+ {
+ if (s == null)
{
- string s = _chars.Slice(0, _pos).ToString();
- Dispose();
- return s;
+ return;
}
- /// Returns the underlying storage of the builder.
- public Span RawChars => _chars;
+ int count = s.Length;
- ///
- /// Returns a span around the contents of the builder.
- ///
- /// Ensures that the builder has a null char after
- public ReadOnlySpan AsSpan(bool terminate)
+ if (_pos > (_chars.Length - count))
{
- if (terminate)
- {
- EnsureCapacity(Length + 1);
- _chars[Length] = '\0';
- }
- return _chars.Slice(0, _pos);
+ Grow(count);
}
- public ReadOnlySpan AsSpan() => _chars.Slice(0, _pos);
- public ReadOnlySpan AsSpan(int start) => _chars.Slice(start, _pos - start);
- public ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length);
+ int remaining = _pos - index;
+ _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
+ s
+#if !NET
+ .AsSpan()
+#endif
+ .CopyTo(_chars.Slice(index));
+ _pos += count;
+ }
- public bool TryCopyTo(Span destination, out int charsWritten)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Append(char c)
+ {
+ int pos = _pos;
+ Span chars = _chars;
+ if ((uint)pos < (uint)chars.Length)
{
- if (_chars.Slice(0, _pos).TryCopyTo(destination))
- {
- charsWritten = _pos;
- Dispose();
- return true;
- }
- else
- {
- charsWritten = 0;
- Dispose();
- return false;
- }
+ chars[pos] = c;
+ _pos = pos + 1;
}
-
- public void Insert(int index, char value, int count)
+ else
{
- if (_pos > _chars.Length - count)
- {
- Grow(count);
- }
-
- int remaining = _pos - index;
- _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
- _chars.Slice(index, count).Fill(value);
- _pos += count;
+ GrowAndAppend(c);
}
+ }
- public void Insert(int index, string? s)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Append(string? s)
+ {
+ if (s == null)
{
- if (s == null)
- {
- return;
- }
-
- int count = s.Length;
-
- if (_pos > (_chars.Length - count))
- {
- Grow(count);
- }
-
- int remaining = _pos - index;
- _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
- s
-#if !NET
- .AsSpan()
-#endif
- .CopyTo(_chars.Slice(index));
- _pos += count;
+ return;
}
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void Append(char c)
+ int pos = _pos;
+ if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc.
{
- int pos = _pos;
- Span chars = _chars;
- if ((uint)pos < (uint)chars.Length)
- {
- chars[pos] = c;
- _pos = pos + 1;
- }
- else
- {
- GrowAndAppend(c);
- }
+ _chars[pos] = s[0];
+ _pos = pos + 1;
}
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void Append(string? s)
+ else
{
- if (s == null)
- {
- return;
- }
-
- int pos = _pos;
- if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc.
- {
- _chars[pos] = s[0];
- _pos = pos + 1;
- }
- else
- {
- AppendSlow(s);
- }
+ AppendSlow(s);
}
+ }
- private void AppendSlow(string s)
+ private void AppendSlow(string s)
+ {
+ int pos = _pos;
+ if (pos > _chars.Length - s.Length)
{
- int pos = _pos;
- if (pos > _chars.Length - s.Length)
- {
- Grow(s.Length);
- }
+ Grow(s.Length);
+ }
- s
+ s
#if !NET
- .AsSpan()
+ .AsSpan()
#endif
- .CopyTo(_chars.Slice(pos));
- _pos += s.Length;
- }
+ .CopyTo(_chars.Slice(pos));
+ _pos += s.Length;
+ }
- public void Append(char c, int count)
+ public void Append(char c, int count)
+ {
+ if (_pos > _chars.Length - count)
{
- if (_pos > _chars.Length - count)
- {
- Grow(count);
- }
-
- Span dst = _chars.Slice(_pos, count);
- for (int i = 0; i < dst.Length; i++)
- {
- dst[i] = c;
- }
- _pos += count;
+ Grow(count);
}
- public void Append(scoped ReadOnlySpan value)
+ Span dst = _chars.Slice(_pos, count);
+ for (int i = 0; i < dst.Length; i++)
{
- int pos = _pos;
- if (pos > _chars.Length - value.Length)
- {
- Grow(value.Length);
- }
-
- value.CopyTo(_chars.Slice(_pos));
- _pos += value.Length;
+ dst[i] = c;
}
+ _pos += count;
+ }
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public Span AppendSpan(int length)
+ public void Append(scoped ReadOnlySpan value)
+ {
+ int pos = _pos;
+ if (pos > _chars.Length - value.Length)
{
- int origPos = _pos;
- if (origPos > _chars.Length - length)
- {
- Grow(length);
- }
-
- _pos = origPos + length;
- return _chars.Slice(origPos, length);
+ Grow(value.Length);
}
- [MethodImpl(MethodImplOptions.NoInlining)]
- private void GrowAndAppend(char c)
+ value.CopyTo(_chars.Slice(_pos));
+ _pos += value.Length;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Span AppendSpan(int length)
+ {
+ int origPos = _pos;
+ if (origPos > _chars.Length - length)
{
- Grow(1);
- Append(c);
+ Grow(length);
}
- ///
- /// Resize the internal buffer either by doubling current buffer size or
- /// by adding to
- /// whichever is greater.
- ///
- ///
- /// Number of chars requested beyond current position.
- ///
- [MethodImpl(MethodImplOptions.NoInlining)]
- private void Grow(int additionalCapacityBeyondPos)
- {
- Debug.Assert(additionalCapacityBeyondPos > 0);
- Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed.");
+ _pos = origPos + length;
+ return _chars.Slice(origPos, length);
+ }
- const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void GrowAndAppend(char c)
+ {
+ Grow(1);
+ Append(c);
+ }
+
+ ///
+ /// Resize the internal buffer either by doubling current buffer size or
+ /// by adding to
+ /// whichever is greater.
+ ///
+ ///
+ /// Number of chars requested beyond current position.
+ ///
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void Grow(int additionalCapacityBeyondPos)
+ {
+ Debug.Assert(additionalCapacityBeyondPos > 0);
+ Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed.");
- // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try
- // to double the size if possible, bounding the doubling to not go beyond the max array length.
- int newCapacity = (int)Math.Max(
- (uint)(_pos + additionalCapacityBeyondPos),
- Math.Min((uint)_chars.Length * 2, ArrayMaxLength));
+ const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength
- // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative.
- // This could also go negative if the actual required length wraps around.
- char[] poolArray = ArrayPool.Shared.Rent(newCapacity);
+ // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try
+ // to double the size if possible, bounding the doubling to not go beyond the max array length.
+ int newCapacity = (int)Math.Max(
+ (uint)(_pos + additionalCapacityBeyondPos),
+ Math.Min((uint)_chars.Length * 2, ArrayMaxLength));
- _chars.Slice(0, _pos).CopyTo(poolArray);
+ // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative.
+ // This could also go negative if the actual required length wraps around.
+ char[] poolArray = ArrayPool.Shared.Rent(newCapacity);
- char[]? toReturn = _arrayToReturnToPool;
- _chars = _arrayToReturnToPool = poolArray;
- if (toReturn != null)
- {
- ArrayPool.Shared.Return(toReturn);
- }
+ _chars.Slice(0, _pos).CopyTo(poolArray);
+
+ char[]? toReturn = _arrayToReturnToPool;
+ _chars = _arrayToReturnToPool = poolArray;
+ if (toReturn != null)
+ {
+ ArrayPool.Shared.Return(toReturn);
}
+ }
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void Dispose()
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Dispose()
+ {
+ char[]? toReturn = _arrayToReturnToPool;
+ this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again
+ if (toReturn != null)
{
- char[]? toReturn = _arrayToReturnToPool;
- this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again
- if (toReturn != null)
- {
- ArrayPool.Shared.Return(toReturn);
- }
+ ArrayPool.Shared.Return(toReturn);
}
}
}
\ No newline at end of file
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 7859ba39a..f229a3eaf 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -6,7 +6,7 @@
https://github.com/modelcontextprotocol/csharp-sdk
git
0.3.0
- preview.3
+ preview.5
ModelContextProtocolOfficial
© Anthropic and Contributors.
ModelContextProtocol;mcp;ai;llm
diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs
index f8c6f41cd..46b8e898b 100644
--- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs
@@ -43,8 +43,7 @@ public async Task HandleRequestAsync()
return false;
}
- await HandleResourceMetadataRequestAsync();
- return true;
+ return await HandleResourceMetadataRequestAsync();
}
///
@@ -78,10 +77,7 @@ private string GetAbsoluteResourceMetadataUri()
return absoluteUri.ToString();
}
- ///
- /// Handles the resource metadata request.
- ///
- private async Task HandleResourceMetadataRequestAsync()
+ private async Task HandleResourceMetadataRequestAsync()
{
var resourceMetadata = Options.ResourceMetadata;
@@ -93,6 +89,23 @@ private async Task HandleResourceMetadataRequestAsync()
};
await Options.Events.OnResourceMetadataRequest(context);
+
+ if (context.Result is not null)
+ {
+ if (context.Result.Handled)
+ {
+ return true;
+ }
+ else if (context.Result.Skipped)
+ {
+ return false;
+ }
+ else if (context.Result.Failure is not null)
+ {
+ throw new AuthenticationFailureException("An error occurred from the OnResourceMetadataRequest event.", context.Result.Failure);
+ }
+ }
+
resourceMetadata = context.ResourceMetadata;
}
@@ -104,6 +117,7 @@ private async Task HandleResourceMetadataRequestAsync()
}
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
+ return true;
}
///
diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs
new file mode 100644
index 000000000..2cfb74d09
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs
@@ -0,0 +1,359 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace ModelContextProtocol.AspNetCore;
+
+///
+/// Evaluates authorization policies from endpoint metadata.
+///
+internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? policyProvider = null) : IConfigureOptions, IPostConfigureOptions
+{
+ private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked";
+
+ public void Configure(McpServerOptions options)
+ {
+ ConfigureListToolsFilter(options);
+ ConfigureCallToolFilter(options);
+
+ ConfigureListResourcesFilter(options);
+ ConfigureListResourceTemplatesFilter(options);
+ ConfigureReadResourceFilter(options);
+
+ ConfigureListPromptsFilter(options);
+ ConfigureGetPromptFilter(options);
+ }
+
+ public void PostConfigure(string? name, McpServerOptions options)
+ {
+ CheckListToolsFilter(options);
+ CheckCallToolFilter(options);
+
+ CheckListResourcesFilter(options);
+ CheckListResourceTemplatesFilter(options);
+ CheckReadResourceFilter(options);
+
+ CheckListPromptsFilter(options);
+ CheckGetPromptFilter(options);
+ }
+
+ private void ConfigureListToolsFilter(McpServerOptions options)
+ {
+ options.Filters.ListToolsFilters.Add(next => async (context, cancellationToken) =>
+ {
+ context.Items[AuthorizationFilterInvokedKey] = true;
+
+ var result = await next(context, cancellationToken);
+ await FilterAuthorizedItemsAsync(
+ result.Tools, static tool => tool.McpServerTool,
+ context.User, context.Services, context);
+ return result;
+ });
+ }
+
+ private void CheckListToolsFilter(McpServerOptions options)
+ {
+ options.Filters.ListToolsFilters.Add(next => async (context, cancellationToken) =>
+ {
+ var result = await next(context, cancellationToken);
+
+ if (HasAuthorizationMetadata(result.Tools.Select(static tool => tool.McpServerTool))
+ && !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
+ {
+ throw new InvalidOperationException("Authorization filter was not invoked for tools/list operation, but authorization metadata was found on the tools. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
+ }
+
+ return result;
+ });
+ }
+
+ private void ConfigureCallToolFilter(McpServerOptions options)
+ {
+ options.Filters.CallToolFilters.Add(next => async (context, cancellationToken) =>
+ {
+ 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);
+ }
+
+ context.Items[AuthorizationFilterInvokedKey] = true;
+
+ return await next(context, cancellationToken);
+ });
+ }
+
+ private void CheckCallToolFilter(McpServerOptions options)
+ {
+ options.Filters.CallToolFilters.Add(next => async (context, cancellationToken) =>
+ {
+ if (HasAuthorizationMetadata(context.MatchedPrimitive)
+ && !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
+ {
+ throw new InvalidOperationException("Authorization filter was not invoked for tools/call operation, but authorization metadata was found on the tool. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
+ }
+
+ return await next(context, cancellationToken);
+ });
+ }
+
+ private void ConfigureListResourcesFilter(McpServerOptions options)
+ {
+ options.Filters.ListResourcesFilters.Add(next => async (context, cancellationToken) =>
+ {
+ context.Items[AuthorizationFilterInvokedKey] = true;
+
+ var result = await next(context, cancellationToken);
+ await FilterAuthorizedItemsAsync(
+ result.Resources, static resource => resource.McpServerResource,
+ context.User, context.Services, context);
+ return result;
+ });
+ }
+
+ private void CheckListResourcesFilter(McpServerOptions options)
+ {
+ options.Filters.ListResourcesFilters.Add(next => async (context, cancellationToken) =>
+ {
+ var result = await next(context, cancellationToken);
+
+ if (HasAuthorizationMetadata(result.Resources.Select(static resource => resource.McpServerResource))
+ && !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
+ {
+ throw new InvalidOperationException("Authorization filter was not invoked for resources/list operation, but authorization metadata was found on the resources. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
+ }
+
+ return result;
+ });
+ }
+
+ private void ConfigureListResourceTemplatesFilter(McpServerOptions options)
+ {
+ options.Filters.ListResourceTemplatesFilters.Add(next => async (context, cancellationToken) =>
+ {
+ context.Items[AuthorizationFilterInvokedKey] = true;
+
+ var result = await next(context, cancellationToken);
+ await FilterAuthorizedItemsAsync(
+ result.ResourceTemplates, static resourceTemplate => resourceTemplate.McpServerResource,
+ context.User, context.Services, context);
+ return result;
+ });
+ }
+
+ private void CheckListResourceTemplatesFilter(McpServerOptions options)
+ {
+ options.Filters.ListResourceTemplatesFilters.Add(next => async (context, cancellationToken) =>
+ {
+ var result = await next(context, cancellationToken);
+
+ if (HasAuthorizationMetadata(result.ResourceTemplates.Select(static resourceTemplate => resourceTemplate.McpServerResource))
+ && !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
+ {
+ throw new InvalidOperationException("Authorization filter was not invoked for resources/templates/list operation, but authorization metadata was found on the resource templates. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
+ }
+
+ return result;
+ });
+ }
+
+ private void ConfigureReadResourceFilter(McpServerOptions options)
+ {
+ options.Filters.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
+ {
+ context.Items[AuthorizationFilterInvokedKey] = true;
+
+ 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);
+ }
+
+ return await next(context, cancellationToken);
+ });
+ }
+
+ private void CheckReadResourceFilter(McpServerOptions options)
+ {
+ options.Filters.ReadResourceFilters.Add(next => async (context, cancellationToken) =>
+ {
+ if (HasAuthorizationMetadata(context.MatchedPrimitive)
+ && !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
+ {
+ throw new InvalidOperationException("Authorization filter was not invoked for resources/read operation, but authorization metadata was found on the resource. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
+ }
+
+ return await next(context, cancellationToken);
+ });
+ }
+
+ private void ConfigureListPromptsFilter(McpServerOptions options)
+ {
+ options.Filters.ListPromptsFilters.Add(next => async (context, cancellationToken) =>
+ {
+ context.Items[AuthorizationFilterInvokedKey] = true;
+
+ var result = await next(context, cancellationToken);
+ await FilterAuthorizedItemsAsync(
+ result.Prompts, static prompt => prompt.McpServerPrompt,
+ context.User, context.Services, context);
+ return result;
+ });
+ }
+
+ private void CheckListPromptsFilter(McpServerOptions options)
+ {
+ options.Filters.ListPromptsFilters.Add(next => async (context, cancellationToken) =>
+ {
+ var result = await next(context, cancellationToken);
+
+ if (HasAuthorizationMetadata(result.Prompts.Select(static prompt => prompt.McpServerPrompt))
+ && !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
+ {
+ throw new InvalidOperationException("Authorization filter was not invoked for prompts/list operation, but authorization metadata was found on the prompts. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
+ }
+
+ return result;
+ });
+ }
+
+ private void ConfigureGetPromptFilter(McpServerOptions options)
+ {
+ options.Filters.GetPromptFilters.Add(next => async (context, cancellationToken) =>
+ {
+ context.Items[AuthorizationFilterInvokedKey] = true;
+
+ 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);
+ }
+
+ return await next(context, cancellationToken);
+ });
+ }
+
+ private void CheckGetPromptFilter(McpServerOptions options)
+ {
+ options.Filters.GetPromptFilters.Add(next => async (context, cancellationToken) =>
+ {
+ if (HasAuthorizationMetadata(context.MatchedPrimitive)
+ && !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
+ {
+ throw new InvalidOperationException("Authorization filter was not invoked for prompts/get operation, but authorization metadata was found on the prompt. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
+ }
+
+ return await next(context, cancellationToken);
+ });
+ }
+
+ ///
+ /// Filters a collection of items based on authorization policies in their metadata.
+ /// For list operations where we need to filter results by authorization.
+ ///
+ private async ValueTask FilterAuthorizedItemsAsync(IList items, Func primitiveSelector,
+ ClaimsPrincipal? user, IServiceProvider? requestServices, object context)
+ {
+ for (int i = items.Count - 1; i >= 0; i--)
+ {
+ var authorizationResult = await GetAuthorizationResultAsync(
+ user, primitiveSelector(items[i]), requestServices, context);
+
+ if (!authorizationResult.Succeeded)
+ {
+ items.RemoveAt(i);
+ }
+ }
+ }
+
+ private async ValueTask GetAuthorizationResultAsync(
+ ClaimsPrincipal? user, IMcpServerPrimitive? primitive, IServiceProvider? requestServices, object context)
+ {
+ if (!HasAuthorizationMetadata(primitive))
+ {
+ return AuthorizationResult.Success();
+ }
+
+ if (policyProvider is null)
+ {
+ throw new InvalidOperationException($"You must call AddAuthorization() because an authorization related attribute was found on {primitive.Id}");
+ }
+
+ var policy = await CombineAsync(policyProvider, primitive.Metadata);
+ if (policy is null)
+ {
+ return AuthorizationResult.Success();
+ }
+
+ if (requestServices is null)
+ {
+ // The IAuthorizationPolicyProvider service must be non-null to get to this line, so it's very unexpected for RequestContext.Services to not be set.
+ throw new InvalidOperationException("RequestContext.Services is not set! The McpServer must be initialized with a non-null IServiceProvider.");
+ }
+
+ // ASP.NET Core's AuthorizationMiddleware resolves the IAuthorizationService from scoped request services, so we do the same.
+ var authService = requestServices.GetRequiredService();
+ return await authService.AuthorizeAsync(user ?? new ClaimsPrincipal(new ClaimsIdentity()), context, policy);
+ }
+
+ ///
+ /// Combines authorization policies and requirements from endpoint metadata without considering .
+ ///
+ /// The authorization policy provider.
+ /// The endpoint metadata collection.
+ /// The combined authorization policy, or null if no authorization is required.
+ private static async ValueTask CombineAsync(IAuthorizationPolicyProvider policyProvider, IReadOnlyList
- public Func? RunSessionHandler { get; set; }
+ 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
@@ -66,9 +66,9 @@ public class HttpServerTransportOptions
/// Past this limit, the server will log a critical error and terminate the oldest idle sessions even if they have not reached
/// their until the idle session count is below this limit. Clients that keep their session open by
/// keeping a GET request open will not count towards this limit.
- /// Defaults to 100,000 sessions.
+ /// Defaults to 10,000 sessions.
///
- public int MaxIdleSessionCount { get; set; } = 100_000;
+ public int MaxIdleSessionCount { get; set; } = 10_000;
///
/// Used for testing the .
diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs
index 26ffd44bb..a4ae569ba 100644
--- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs
+++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs
@@ -1,17 +1,16 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using ModelContextProtocol.Server;
namespace ModelContextProtocol.AspNetCore;
internal sealed partial class IdleTrackingBackgroundService(
- StreamableHttpHandler handler,
+ StatefulSessionManager sessions,
IOptions options,
IHostApplicationLifetime appLifetime,
ILogger logger) : BackgroundService
{
- // The compiler will complain about the parameter being unused otherwise despite the source generator.
+ // Workaround for https://github.com/dotnet/runtime/issues/91121. This is fixed in .NET 9 and later.
private readonly ILogger _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -21,6 +20,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero);
}
+
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0);
try
@@ -28,54 +28,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
var timeProvider = options.Value.TimeProvider;
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider);
- var idleTimeoutTicks = options.Value.IdleTimeout.Ticks;
- var maxIdleSessionCount = options.Value.MaxIdleSessionCount;
-
- // The default ValueTuple Comparer will check the first item then the second which preserves both order and uniqueness.
- var idleSessions = new SortedSet<(long Timestamp, string SessionId)>();
-
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
- var idleActivityCutoff = idleTimeoutTicks switch
- {
- < 0 => long.MinValue,
- var ticks => timeProvider.GetTimestamp() - ticks,
- };
-
- foreach (var (_, session) in handler.Sessions)
- {
- if (session.IsActive || session.SessionClosed.IsCancellationRequested)
- {
- // There's a request currently active or the session is already being closed.
- continue;
- }
-
- if (session.LastActivityTicks < idleActivityCutoff)
- {
- RemoveAndCloseSession(session.Id);
- continue;
- }
-
- idleSessions.Add((session.LastActivityTicks, session.Id));
-
- // Emit critical log at most once every 5 seconds the idle count it exceeded,
- // since the IdleTimeout will no longer be respected.
- if (idleSessions.Count == maxIdleSessionCount + 1)
- {
- LogMaxSessionIdleCountExceeded(maxIdleSessionCount);
- }
- }
-
- if (idleSessions.Count > maxIdleSessionCount)
- {
- var sessionsToPrune = idleSessions.ToArray()[..^maxIdleSessionCount];
- foreach (var (_, id) in sessionsToPrune)
- {
- RemoveAndCloseSession(id);
- }
- }
-
- idleSessions.Clear();
+ await sessions.PruneIdleSessionsAsync(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -85,17 +40,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
- List disposeSessionTasks = [];
-
- foreach (var (sessionKey, _) in handler.Sessions)
- {
- if (handler.Sessions.TryRemove(sessionKey, out var session))
- {
- disposeSessionTasks.Add(DisposeSessionAsync(session));
- }
- }
-
- await Task.WhenAll(disposeSessionTasks);
+ await sessions.DisposeAllSessionsAsync();
}
finally
{
@@ -110,39 +55,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
}
- private void RemoveAndCloseSession(string sessionId)
- {
- if (!handler.Sessions.TryRemove(sessionId, out var session))
- {
- return;
- }
-
- LogSessionIdle(session.Id);
- // Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
- _ = DisposeSessionAsync(session);
- }
-
- private async Task DisposeSessionAsync(HttpMcpSession session)
- {
- try
- {
- await session.DisposeAsync();
- }
- catch (Exception ex)
- {
- LogSessionDisposeError(session.Id, ex);
- }
- }
-
- [LoggerMessage(Level = LogLevel.Information, Message = "Closing idle session {sessionId}.")]
- private partial void LogSessionIdle(string sessionId);
-
- [LoggerMessage(Level = LogLevel.Error, Message = "Error disposing session {sessionId}.")]
- private partial void LogSessionDisposeError(string sessionId, Exception ex);
-
- [LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded maximum of {maxIdleSessionCount} idle sessions. Now closing sessions active more recently than configured IdleTimeout.")]
- private partial void LogMaxSessionIdleCountExceeded(int maxIdleSessionCount);
-
[LoggerMessage(Level = LogLevel.Critical, Message = "The IdleTrackingBackgroundService has stopped unexpectedly.")]
private partial void IdleTrackingBackgroundServiceStoppedUnexpectedly();
-}
+}
\ No newline at end of file
diff --git a/src/ModelContextProtocol.AspNetCore/SseHandler.cs b/src/ModelContextProtocol.AspNetCore/SseHandler.cs
index c5ac5a948..eefe0d29e 100644
--- a/src/ModelContextProtocol.AspNetCore/SseHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/SseHandler.cs
@@ -2,7 +2,6 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Collections.Concurrent;
using System.Diagnostics;
@@ -16,7 +15,7 @@ internal sealed class SseHandler(
IHostApplicationLifetime hostApplicationLifetime,
ILoggerFactory loggerFactory)
{
- private readonly ConcurrentDictionary> _sessions = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal);
public async Task HandleSseRequestAsync(HttpContext context)
{
@@ -34,9 +33,9 @@ public async Task HandleSseRequestAsync(HttpContext context)
await using var transport = new SseResponseStreamTransport(context.Response.Body, $"{endpointPattern}message?sessionId={sessionId}", sessionId);
var userIdClaim = StreamableHttpHandler.GetUserIdClaim(context.User);
- await using var httpMcpSession = new HttpMcpSession(sessionId, transport, userIdClaim, httpMcpServerOptions.Value.TimeProvider);
+ var sseSession = new SseSession(transport, userIdClaim);
- if (!_sessions.TryAdd(sessionId, httpMcpSession))
+ if (!_sessions.TryAdd(sessionId, sseSession))
{
throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
}
@@ -54,13 +53,11 @@ public async Task HandleSseRequestAsync(HttpContext context)
try
{
- await using var mcpServer = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, context.RequestServices);
- httpMcpSession.Server = mcpServer;
+ await using var mcpServer = McpServer.Create(transport, mcpServerOptions, loggerFactory, context.RequestServices);
context.Features.Set(mcpServer);
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? StreamableHttpHandler.RunSessionAsync;
- httpMcpSession.ServerRunTask = runSessionAsync(context, mcpServer, cancellationToken);
- await httpMcpSession.ServerRunTask;
+ await runSessionAsync(context, mcpServer, cancellationToken);
}
finally
{
@@ -87,27 +84,29 @@ public async Task HandleMessageRequestAsync(HttpContext context)
return;
}
- if (!_sessions.TryGetValue(sessionId.ToString(), out var httpMcpSession))
+ if (!_sessions.TryGetValue(sessionId.ToString(), out var sseSession))
{
await Results.BadRequest($"Session ID not found.").ExecuteAsync(context);
return;
}
- if (!httpMcpSession.HasSameUserId(context.User))
+ if (sseSession.UserId != StreamableHttpHandler.GetUserIdClaim(context.User))
{
await Results.Forbid().ExecuteAsync(context);
return;
}
- var message = (JsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), context.RequestAborted);
+ var message = await StreamableHttpHandler.ReadJsonRpcMessageAsync(context);
if (message is null)
{
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
return;
}
- await httpMcpSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted);
+ await sseSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted);
context.Response.StatusCode = StatusCodes.Status202Accepted;
await context.Response.WriteAsync("Accepted");
}
+
+ private record SseSession(SseResponseStreamTransport Transport, UserIdClaim? UserId);
}
diff --git a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs
new file mode 100644
index 000000000..960488af7
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs
@@ -0,0 +1,243 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.InteropServices;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ModelContextProtocol.AspNetCore;
+
+internal sealed partial class StatefulSessionManager(
+ IOptions httpServerTransportOptions,
+ ILogger logger)
+{
+ // Workaround for https://github.com/dotnet/runtime/issues/91121. This is fixed in .NET 9 and later.
+ private readonly ILogger _logger = logger;
+
+ private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal);
+
+ private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider;
+ private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout;
+ private readonly long _idleTimeoutTicks = httpServerTransportOptions.Value.IdleTimeout.Ticks;
+ private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount;
+
+ private readonly object _idlePruningLock = new();
+ private readonly List _idleTimestamps = [];
+ private readonly List _idleSessionIds = [];
+ private int _nextIndexToPrune;
+
+ private long _currentIdleSessionCount;
+
+ public TimeProvider TimeProvider => _timeProvider;
+
+ public void IncrementIdleSessionCount() => Interlocked.Increment(ref _currentIdleSessionCount);
+ public void DecrementIdleSessionCount() => Interlocked.Decrement(ref _currentIdleSessionCount);
+
+ public bool TryGetValue(string key, [NotNullWhen(true)] out StreamableHttpSession? value) => _sessions.TryGetValue(key, out value);
+ public bool TryRemove(string key, [NotNullWhen(true)] out StreamableHttpSession? value) => _sessions.TryRemove(key, out value);
+
+ public async ValueTask StartNewSessionAsync(StreamableHttpSession newSession, CancellationToken cancellationToken)
+ {
+ while (!TryAddSessionImmediately(newSession))
+ {
+ StreamableHttpSession? sessionToPrune = null;
+
+ lock (_idlePruningLock)
+ {
+ EnsureIdleSessionsSortedUnsynchronized();
+
+ while (_nextIndexToPrune < _idleSessionIds.Count)
+ {
+ var pruneId = _idleSessionIds[_nextIndexToPrune++];
+ if (_sessions.TryRemove(pruneId, out sessionToPrune))
+ {
+ LogIdleSessionLimit(pruneId, _maxIdleSessionCount);
+ break;
+ }
+ }
+
+ if (sessionToPrune is null)
+ {
+ // If we couldn't find any active idle sessions to dispose, start another full prune to repopulate _idleSessionIds.
+ PruneIdleSessionsUnsynchronized();
+
+ if (_idleSessionIds.Count > 0)
+ {
+ continue;
+ }
+ else
+ {
+ // This indicates all idle sessions are in the process of being disposed which should not happen during normal operation.
+ // Since there are no idle sessions to prune right now, log a critical error and create the new session anyway.
+ LogTooManyIdleSessionsClosingConcurrently(newSession.Id, _maxIdleSessionCount, Volatile.Read(ref _currentIdleSessionCount));
+ AddSession(newSession);
+ return;
+ }
+ }
+ }
+
+ try
+ {
+ // Since we're at or above the maximum idle session count, we're intentionally waiting for the idle session to be disposed
+ // before adding a new session to the dictionary to ensure sessions not created faster than they're removed.
+ await DisposeSessionAsync(sessionToPrune);
+
+ // Take one last chance to check if the initialize request was aborted before we incur the cost of managing a new session.
+ cancellationToken.ThrowIfCancellationRequested();
+ AddSession(newSession);
+ return;
+ }
+ catch
+ {
+ await newSession.DisposeAsync();
+ throw;
+ }
+ }
+ }
+
+ ///
+ /// Performs a single pass of idle session pruning, removing sessions that exceed the idle timeout
+ /// or when the maximum idle session count is exceeded.
+ ///
+ public async Task PruneIdleSessionsAsync(CancellationToken cancellationToken)
+ {
+ lock (_idlePruningLock)
+ {
+ PruneIdleSessionsUnsynchronized();
+ }
+ }
+
+ private void PruneIdleSessionsUnsynchronized()
+ {
+ var idleActivityCutoff = _idleTimeoutTicks switch
+ {
+ < 0 => long.MinValue,
+ var ticks => _timeProvider.GetTimestamp() - ticks,
+ };
+
+ // We clear the lists at the start of pruning rather than the end so we can use them between runs
+ // to find the most idle sessions to remove one-at-a-time if necessary to make room for new sessions.
+ _idleTimestamps.Clear();
+ _idleSessionIds.Clear();
+ _nextIndexToPrune = -1;
+
+ foreach (var (_, session) in _sessions)
+ {
+ if (session.IsActive || session.SessionClosed.IsCancellationRequested)
+ {
+ // There's a request currently active or the session is already being closed.
+ continue;
+ }
+
+ if (session.LastActivityTicks < idleActivityCutoff)
+ {
+ LogIdleSessionTimeout(session.Id, _idleTimeout);
+ RemoveAndCloseSession(session.Id);
+ continue;
+ }
+
+ // Add the timestamp and the session
+ _idleTimestamps.Add(session.LastActivityTicks);
+ _idleSessionIds.Add(session.Id);
+ }
+
+ if (_idleTimestamps.Count > _maxIdleSessionCount)
+ {
+ // Sort only if the maximum is breached and sort solely by the timestamp.
+ EnsureIdleSessionsSortedUnsynchronized();
+
+ var sessionsToPrune = CollectionsMarshal.AsSpan(_idleSessionIds)[..^_maxIdleSessionCount];
+ foreach (var id in sessionsToPrune)
+ {
+ LogIdleSessionLimit(id, _maxIdleSessionCount);
+ RemoveAndCloseSession(id);
+ }
+ _nextIndexToPrune = _maxIdleSessionCount;
+ }
+ }
+
+ private void EnsureIdleSessionsSortedUnsynchronized()
+ {
+ if (_nextIndexToPrune > -1)
+ {
+ // Already sorted.
+ return;
+ }
+
+ var timestamps = CollectionsMarshal.AsSpan(_idleTimestamps);
+ timestamps.Sort(CollectionsMarshal.AsSpan(_idleSessionIds));
+ _nextIndexToPrune = 0;
+ }
+
+ ///
+ /// Disposes all sessions in the manager, typically called during graceful shutdown.
+ ///
+ public async Task DisposeAllSessionsAsync()
+ {
+ List disposeSessionTasks = [];
+
+ foreach (var (sessionKey, _) in _sessions)
+ {
+ if (_sessions.TryRemove(sessionKey, out var session))
+ {
+ disposeSessionTasks.Add(DisposeSessionAsync(session));
+ }
+ }
+
+ await Task.WhenAll(disposeSessionTasks);
+ }
+
+ private bool TryAddSessionImmediately(StreamableHttpSession session)
+ {
+ if (Volatile.Read(ref _currentIdleSessionCount) < _maxIdleSessionCount)
+ {
+ AddSession(session);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void AddSession(StreamableHttpSession session)
+ {
+ if (!_sessions.TryAdd(session.Id, session))
+ {
+ throw new UnreachableException($"Unreachable given good entropy! Session with ID '{session.Id}' has already been created.");
+ }
+ }
+
+ private void RemoveAndCloseSession(string sessionId)
+ {
+ if (!_sessions.TryRemove(sessionId, out var session))
+ {
+ return;
+ }
+
+ // Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
+ _ = DisposeSessionAsync(session);
+ }
+
+ private async Task DisposeSessionAsync(StreamableHttpSession session)
+ {
+ try
+ {
+ await session.DisposeAsync();
+ }
+ catch (Exception ex)
+ {
+ LogSessionDisposeError(session.Id, ex);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "IdleTimeout of {IdleTimeout} exceeded. Closing idle session {SessionId}.")]
+ private partial void LogIdleSessionTimeout(string sessionId, TimeSpan idleTimeout);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "MaxIdleSessionCount of {MaxIdleSessionCount} exceeded. Closing idle session {SessionId} despite it being active more recently than the configured IdleTimeout to make room for new sessions.")]
+ private partial void LogIdleSessionLimit(string sessionId, int maxIdleSessionCount);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Error disposing session {SessionId}.")]
+ private partial void LogSessionDisposeError(string sessionId, Exception ex);
+
+ [LoggerMessage(Level = LogLevel.Critical, Message = "MaxIdleSessionCount of {MaxIdleSessionCount} exceeded, and {CurrentIdleSessionCount} sessions are currently in the process of closing. Creating new session {SessionId} anyway.")]
+ private partial void LogTooManyIdleSessionsClosingConcurrently(string sessionId, int maxIdleSessionCount, long currentIdleSessionCount);
+}
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index 6dac1c3e4..14093facc 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -8,9 +8,6 @@
using ModelContextProtocol.AspNetCore.Stateless;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
-using System.Collections.Concurrent;
-using System.Diagnostics;
-using System.IO.Pipelines;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
@@ -22,14 +19,15 @@ internal sealed class StreamableHttpHandler(
IOptions mcpServerOptionsSnapshot,
IOptionsFactory mcpServerOptionsFactory,
IOptions httpServerTransportOptions,
+ StatefulSessionManager sessionManager,
IDataProtectionProvider dataProtection,
ILoggerFactory loggerFactory,
IServiceProvider applicationServices)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
- private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo();
- public ConcurrentDictionary> Sessions { get; } = new(StringComparer.Ordinal);
+ private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo();
+ private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo();
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
@@ -56,28 +54,24 @@ await WriteJsonRpcErrorAsync(context,
return;
}
- try
- {
- using var _ = session.AcquireReference();
+ await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
- InitializeSseResponse(context);
- var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), context.RequestAborted);
- if (!wroteResponse)
- {
- // We wound up writing nothing, so there should be no Content-Type response header.
- context.Response.Headers.ContentType = (string?)null;
- context.Response.StatusCode = StatusCodes.Status202Accepted;
- }
+ var message = await ReadJsonRpcMessageAsync(context);
+ if (message is null)
+ {
+ await WriteJsonRpcErrorAsync(context,
+ "Bad Request: The POST body did not contain a valid JSON-RPC message.",
+ StatusCodes.Status400BadRequest);
+ return;
}
- finally
+
+ InitializeSseResponse(context);
+ var wroteResponse = await session.Transport.HandlePostRequest(message, context.Response.Body, context.RequestAborted);
+ if (!wroteResponse)
{
- // Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the Mcp-Session-Id.
- // Non-stateless sessions are 1:1 with the Mcp-Session-Id and outlive the POST request.
- // Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService.
- if (HttpServerTransportOptions.Stateless)
- {
- await session.DisposeAsync();
- }
+ // We wound up writing nothing, so there should be no Content-Type response header.
+ context.Response.Headers.ContentType = (string?)null;
+ context.Response.StatusCode = StatusCodes.Status202Accepted;
}
}
@@ -106,7 +100,7 @@ await WriteJsonRpcErrorAsync(context,
return;
}
- using var _ = session.AcquireReference();
+ await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
InitializeSseResponse(context);
// We should flush headers to indicate a 200 success quickly, because the initialization response
@@ -119,17 +113,22 @@ await WriteJsonRpcErrorAsync(context,
public async Task HandleDeleteRequestAsync(HttpContext context)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
- if (Sessions.TryRemove(sessionId, out var session))
+ if (sessionManager.TryRemove(sessionId, out var session))
{
await session.DisposeAsync();
}
}
- private async ValueTask?> GetSessionAsync(HttpContext context, string sessionId)
+ private async ValueTask GetSessionAsync(HttpContext context, string sessionId)
{
- HttpMcpSession? session;
+ StreamableHttpSession? session;
- if (HttpServerTransportOptions.Stateless)
+ if (string.IsNullOrEmpty(sessionId))
+ {
+ await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
+ return null;
+ }
+ else if (HttpServerTransportOptions.Stateless)
{
var sessionJson = Protector.Unprotect(sessionId);
var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId);
@@ -140,7 +139,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
};
session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId);
}
- else if (!Sessions.TryGetValue(sessionId, out session))
+ else if (!sessionManager.TryGetValue(sessionId, out session))
{
// -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
@@ -163,7 +162,7 @@ await WriteJsonRpcErrorAsync(context,
return session;
}
- private async ValueTask?> GetOrCreateSessionAsync(HttpContext context)
+ private async ValueTask GetOrCreateSessionAsync(HttpContext context)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
@@ -177,7 +176,7 @@ await WriteJsonRpcErrorAsync(context,
}
}
- private async ValueTask> StartNewSessionAsync(HttpContext context)
+ private async ValueTask StartNewSessionAsync(HttpContext context)
{
string sessionId;
StreamableHttpServerTransport transport;
@@ -204,21 +203,10 @@ private async ValueTask> StartNewS
ScheduleStatelessSessionIdWrite(context, transport);
}
- var session = await CreateSessionAsync(context, transport, sessionId);
-
- // The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the MCP-Session-Id.
- if (!HttpServerTransportOptions.Stateless)
- {
- if (!Sessions.TryAdd(sessionId, session))
- {
- throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
- }
- }
-
- return session;
+ return await CreateSessionAsync(context, transport, sessionId);
}
- private async ValueTask> CreateSessionAsync(
+ private async ValueTask CreateSessionAsync(
HttpContext context,
StreamableHttpServerTransport transport,
string sessionId,
@@ -244,14 +232,11 @@ private async ValueTask> CreateSes
}
}
- var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
+ var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices);
context.Features.Set(server);
var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User);
- var session = new HttpMcpSession(sessionId, transport, userIdClaim, HttpServerTransportOptions.TimeProvider)
- {
- Server = server,
- };
+ var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager);
var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync;
session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed);
@@ -289,6 +274,22 @@ internal static string MakeNewSessionId()
return WebEncoders.Base64UrlEncode(buffer);
}
+ internal static async Task ReadJsonRpcMessageAsync(HttpContext context)
+ {
+ // Implementation for reading a JSON-RPC message from the request body
+ var message = await context.Request.ReadFromJsonAsync(s_messageTypeInfo, context.RequestAborted);
+
+ if (context.User?.Identity?.IsAuthenticated == true && message is not null)
+ {
+ message.Context = new()
+ {
+ User = context.User,
+ };
+ }
+
+ return message;
+ }
+
private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport)
{
transport.OnInitRequestReceived = initRequestParams =>
@@ -306,7 +307,7 @@ private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttp
};
}
- internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
+ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
=> session.RunAsync(requestAborted);
// SignalR only checks for ClaimTypes.NameIdentifier in HttpConnectionDispatcher, but AspNetCore.Antiforgery checks that plus the sub and UPN claims.
@@ -329,17 +330,11 @@ internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session
return null;
}
- private static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
+ internal static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("application/json");
private static bool MatchesTextEventStreamMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("text/event-stream");
-
- private sealed class HttpDuplexPipe(HttpContext context) : IDuplexPipe
- {
- public PipeReader Input => context.Request.BodyReader;
- public PipeWriter Output => context.Response.BodyWriter;
- }
}
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs
new file mode 100644
index 000000000..1e8d22dec
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs
@@ -0,0 +1,154 @@
+using ModelContextProtocol.Server;
+using System.Diagnostics;
+using System.Security.Claims;
+
+namespace ModelContextProtocol.AspNetCore;
+
+internal sealed class StreamableHttpSession(
+ string sessionId,
+ StreamableHttpServerTransport transport,
+ McpServer server,
+ UserIdClaim? userId,
+ StatefulSessionManager sessionManager) : IAsyncDisposable
+{
+ private int _referenceCount;
+ private SessionState _state;
+ private readonly object _stateLock = new();
+
+ private int _getRequestStarted;
+ private readonly CancellationTokenSource _disposeCts = new();
+
+ public string Id => sessionId;
+ public StreamableHttpServerTransport Transport => transport;
+ public McpServer Server => server;
+ private StatefulSessionManager SessionManager => sessionManager;
+
+ public CancellationToken SessionClosed => _disposeCts.Token;
+ public bool IsActive => !SessionClosed.IsCancellationRequested && _referenceCount > 0;
+ public long LastActivityTicks { get; private set; } = sessionManager.TimeProvider.GetTimestamp();
+
+ public Task ServerRunTask { get; set; } = Task.CompletedTask;
+
+ public async ValueTask AcquireReferenceAsync(CancellationToken cancellationToken)
+ {
+ // The StreamableHttpSession is not stored between requests in stateless mode. Instead, the session is recreated from the MCP-Session-Id.
+ // Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the Mcp-Session-Id.
+ // Non-stateless sessions are 1:1 with the Mcp-Session-Id and outlive the POST request.
+ // Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService.
+ if (transport.Stateless)
+ {
+ return this;
+ }
+
+ SessionState startingState;
+
+ lock (_stateLock)
+ {
+ startingState = _state;
+ _referenceCount++;
+
+ switch (startingState)
+ {
+ case SessionState.Uninitialized:
+ Debug.Assert(_referenceCount == 1, "The _referenceCount should start at 1 when the StreamableHttpSession is uninitialized.");
+ _state = SessionState.Started;
+ break;
+ case SessionState.Started:
+ if (_referenceCount == 1)
+ {
+ sessionManager.DecrementIdleSessionCount();
+ }
+ break;
+ case SessionState.Disposed:
+ throw new ObjectDisposedException(nameof(StreamableHttpSession));
+ }
+ }
+
+ if (startingState == SessionState.Uninitialized)
+ {
+ await sessionManager.StartNewSessionAsync(this, cancellationToken);
+ }
+
+ return new UnreferenceDisposable(this);
+ }
+
+ public bool TryStartGetRequest() => Interlocked.Exchange(ref _getRequestStarted, 1) == 0;
+ public bool HasSameUserId(ClaimsPrincipal user) => userId == StreamableHttpHandler.GetUserIdClaim(user);
+
+ public async ValueTask DisposeAsync()
+ {
+ var wasIdle = false;
+
+ lock (_stateLock)
+ {
+ switch (_state)
+ {
+ case SessionState.Uninitialized:
+ break;
+ case SessionState.Started:
+ if (_referenceCount == 0)
+ {
+ wasIdle = true;
+ }
+ break;
+ case SessionState.Disposed:
+ return;
+ }
+
+ _state = SessionState.Disposed;
+ }
+
+ try
+ {
+ try
+ {
+ // Dispose transport first to complete the incoming MessageReader gracefully and avoid a potentially unnecessary OCE.
+ await transport.DisposeAsync();
+ await _disposeCts.CancelAsync();
+
+ await ServerRunTask;
+ }
+ finally
+ {
+ await server.DisposeAsync();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ finally
+ {
+ if (wasIdle)
+ {
+ sessionManager.DecrementIdleSessionCount();
+ }
+ _disposeCts.Dispose();
+ }
+ }
+
+ private sealed class UnreferenceDisposable(StreamableHttpSession session) : IAsyncDisposable
+ {
+ public ValueTask DisposeAsync()
+ {
+ lock (session._stateLock)
+ {
+ Debug.Assert(session._state != SessionState.Uninitialized, "The session should have been initialized.");
+ if (session._state != SessionState.Disposed && --session._referenceCount == 0)
+ {
+ var sessionManager = session.SessionManager;
+ session.LastActivityTicks = sessionManager.TimeProvider.GetTimestamp();
+ sessionManager.IncrementIdleSessionCount();
+ }
+ }
+
+ return default;
+ }
+ }
+
+ private enum SessionState
+ {
+ Uninitialized,
+ Started,
+ Disposed
+ }
+}
diff --git a/src/ModelContextProtocol.AspNetCore/Stateless/UserIdClaim.cs b/src/ModelContextProtocol.AspNetCore/UserIdClaim.cs
similarity index 58%
rename from src/ModelContextProtocol.AspNetCore/Stateless/UserIdClaim.cs
rename to src/ModelContextProtocol.AspNetCore/UserIdClaim.cs
index f18c1c5ff..5b5951d3d 100644
--- a/src/ModelContextProtocol.AspNetCore/Stateless/UserIdClaim.cs
+++ b/src/ModelContextProtocol.AspNetCore/UserIdClaim.cs
@@ -1,3 +1,3 @@
-namespace ModelContextProtocol.AspNetCore.Stateless;
+namespace ModelContextProtocol.AspNetCore;
internal sealed record UserIdClaim(string Type, string Value, string Issuer);
diff --git a/src/ModelContextProtocol.Core/AssemblyNameHelper.cs b/src/ModelContextProtocol.Core/AssemblyNameHelper.cs
new file mode 100644
index 000000000..292ed2f96
--- /dev/null
+++ b/src/ModelContextProtocol.Core/AssemblyNameHelper.cs
@@ -0,0 +1,9 @@
+using System.Reflection;
+
+namespace ModelContextProtocol;
+
+internal static class AssemblyNameHelper
+{
+ /// Cached naming information used for MCP session name/version when none is specified.
+ public static AssemblyName DefaultAssemblyName { get; } = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()).GetName();
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
index 686316f55..cc6a8952e 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
@@ -68,22 +68,12 @@ public sealed class ClientOAuthOptions
public Func, Uri?>? AuthServerSelector { get; set; }
///
- /// Gets or sets the client name to use during dynamic client registration.
+ /// Gets or sets the options to use during dynamic client registration.
///
///
- /// This is a human-readable name for the client that may be displayed to users during authorization.
/// Only used when a is not specified.
///
- public string? ClientName { get; set; }
-
- ///
- /// Gets or sets the client URI to use during dynamic client registration.
- ///
- ///
- /// This should be a URL pointing to the client's home page or information page.
- /// Only used when a is not specified.
- ///
- public Uri? ClientUri { get; set; }
+ public DynamicClientRegistrationOptions? DynamicClientRegistration { get; set; }
///
/// Gets or sets additional parameters to include in the query string of the OAuth authorization request
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index 96356028f..b72f775c4 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
-using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
+using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -28,9 +28,11 @@ internal sealed partial class ClientOAuthProvider
private readonly Func, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
- // _clientName and _client URI is used for dynamic client registration (RFC 7591)
- private readonly string? _clientName;
- private readonly Uri? _clientUri;
+ // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
+ private readonly string? _dcrClientName;
+ private readonly Uri? _dcrClientUri;
+ private readonly string? _dcrInitialAccessToken;
+ private readonly Func? _dcrResponseDelegate;
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
@@ -66,9 +68,7 @@ public ClientOAuthProvider(
_clientId = options.ClientId;
_clientSecret = options.ClientSecret;
- _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.");
- _clientName = options.ClientName;
- _clientUri = options.ClientUri;
+ _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_scopes = options.Scopes?.ToArray();
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
@@ -77,6 +77,11 @@ public ClientOAuthProvider(
// Set up authorization URL handler (use default if not provided)
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
+
+ _dcrClientName = options.DynamicClientRegistration?.ClientName;
+ _dcrClientUri = options.DynamicClientRegistration?.ClientUri;
+ _dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
+ _dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate;
}
///
@@ -212,11 +217,6 @@ private async Task PerformOAuthAuthorizationAsync(
// Get auth server metadata
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
- if (authServerMetadata is null)
- {
- ThrowFailedToHandleUnauthorizedResponse($"Failed to retrieve metadata for authorization server: '{selectedAuthServer}'");
- }
-
// Store auth server metadata for future refresh operations
_authServerMetadata = authServerMetadata;
@@ -238,7 +238,7 @@ private async Task PerformOAuthAuthorizationAsync(
LogOAuthAuthorizationCompleted();
}
- private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
+ private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
{
if (!authServerUri.OriginalString.EndsWith("/"))
{
@@ -249,7 +249,9 @@ private async Task PerformOAuthAuthorizationAsync(
{
try
{
- var response = await _httpClient.GetAsync(new Uri(authServerUri, path), cancellationToken).ConfigureAwait(false);
+ var wellKnownEndpoint = new Uri(authServerUri, path);
+
+ var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
continue;
@@ -258,15 +260,28 @@ private async Task PerformOAuthAuthorizationAsync(
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var metadata = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, cancellationToken).ConfigureAwait(false);
- if (metadata != null)
+ if (metadata is null)
+ {
+ continue;
+ }
+
+ if (metadata.AuthorizationEndpoint is null)
{
- metadata.ResponseTypesSupported ??= ["code"];
- metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"];
- metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"];
- metadata.CodeChallengeMethodsSupported ??= ["S256"];
+ ThrowFailedToHandleUnauthorizedResponse($"No authorization_endpoint was provided via '{wellKnownEndpoint}'.");
+ }
- return metadata;
+ if (metadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttp &&
+ metadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttps)
+ {
+ ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement.");
}
+
+ metadata.ResponseTypesSupported ??= ["code"];
+ metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"];
+ metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"];
+ metadata.CodeChallengeMethodsSupported ??= ["S256"];
+
+ return metadata;
}
catch (Exception ex)
{
@@ -274,7 +289,7 @@ private async Task PerformOAuthAuthorizationAsync(
}
}
- return null;
+ throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'");
}
private async Task RefreshTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
@@ -320,12 +335,6 @@ private Uri BuildAuthorizationUrl(
AuthorizationServerMetadata authServerMetadata,
string codeChallenge)
{
- if (authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttp &&
- authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttps)
- {
- throw new ArgumentException("AuthorizationEndpoint must use HTTP or HTTPS.", nameof(authServerMetadata));
- }
-
var queryParamsDictionary = new Dictionary
{
["client_id"] = GetClientIdOrThrow(),
@@ -443,8 +452,8 @@ private async Task PerformDynamicClientRegistrationAsync(
GrantTypes = ["authorization_code", "refresh_token"],
ResponseTypes = ["code"],
TokenEndpointAuthMethod = "client_secret_post",
- ClientName = _clientName,
- ClientUri = _clientUri?.ToString(),
+ ClientName = _dcrClientName,
+ ClientUri = _dcrClientUri?.ToString(),
Scope = _scopes is not null ? string.Join(" ", _scopes) : null
};
@@ -456,6 +465,11 @@ private async Task PerformDynamicClientRegistrationAsync(
Content = requestContent
};
+ if (!string.IsNullOrEmpty(_dcrInitialAccessToken))
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, _dcrInitialAccessToken);
+ }
+
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!httpResponse.IsSuccessStatusCode)
@@ -483,6 +497,11 @@ private async Task PerformDynamicClientRegistrationAsync(
}
LogDynamicClientRegistrationSuccessful(_clientId!);
+
+ if (_dcrResponseDelegate is not null)
+ {
+ await _dcrResponseDelegate(registrationResponse, cancellationToken).ConfigureAwait(false);
+ }
}
///
diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs
new file mode 100644
index 000000000..c7337122e
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs
@@ -0,0 +1,49 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Provides configuration options for the related to dynamic client registration (RFC 7591).
+///
+public sealed class DynamicClientRegistrationOptions
+{
+ ///
+ /// Gets or sets the client name to use during dynamic client registration.
+ ///
+ ///
+ /// This is a human-readable name for the client that may be displayed to users during authorization.
+ ///
+ public string? ClientName { get; set; }
+
+ ///
+ /// Gets or sets the client URI to use during dynamic client registration.
+ ///
+ ///
+ /// This should be a URL pointing to the client's home page or information page.
+ ///
+ public Uri? ClientUri { get; set; }
+
+ ///
+ /// Gets or sets the initial access token to use during dynamic client registration.
+ ///
+ ///
+ ///
+ /// This token is used to authenticate the client during the registration process.
+ ///
+ ///
+ /// This is required if the authorization server does not allow anonymous client registration.
+ ///
+ ///
+ public string? InitialAccessToken { get; set; }
+
+ ///
+ /// Gets or sets the delegate used for handling the dynamic client registration response.
+ ///
+ ///
+ ///
+ /// This delegate is responsible for processing the response from the dynamic client registration endpoint.
+ ///
+ ///
+ /// The implementation should save the client credentials securely for future use.
+ ///
+ ///
+ public Func? ResponseDelegate { get; set; }
+}
\ No newline at end of file
diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs
index dcd51d68a..1dfe12294 100644
--- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs
+++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationResponse.cs
@@ -5,7 +5,7 @@ namespace ModelContextProtocol.Authentication;
///
/// Represents a client registration response for OAuth 2.0 Dynamic Client Registration (RFC 7591).
///
-internal sealed class DynamicClientRegistrationResponse
+public sealed class DynamicClientRegistrationResponse
{
///
/// Gets or sets the client identifier.
diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
index 06f2e0bfb..2e49babcf 100644
--- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
@@ -12,14 +12,14 @@ namespace ModelContextProtocol.Client;
///
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
{
- private readonly SseClientTransportOptions _options;
+ private readonly HttpClientTransportOptions _options;
private readonly McpHttpClient _httpClient;
private readonly ILoggerFactory? _loggerFactory;
private readonly ILogger _logger;
private readonly string _name;
private readonly Channel _messageChannel;
- public AutoDetectingClientSessionTransport(string endpointName, SseClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory)
+ public AutoDetectingClientSessionTransport(string endpointName, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory)
{
Throw.IfNull(transportOptions);
Throw.IfNull(httpClient);
diff --git a/src/ModelContextProtocol.Core/Client/SseClientTransport.cs b/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs
similarity index 86%
rename from src/ModelContextProtocol.Core/Client/SseClientTransport.cs
rename to src/ModelContextProtocol.Core/Client/HttpClientTransport.cs
index b31c3479b..322b9175e 100644
--- a/src/ModelContextProtocol.Core/Client/SseClientTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs
@@ -13,26 +13,26 @@ namespace ModelContextProtocol.Client;
/// Unlike the , this transport connects to an existing server
/// rather than launching a new process.
///
-public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
+public sealed class HttpClientTransport : IClientTransport, IAsyncDisposable
{
- private readonly SseClientTransportOptions _options;
+ private readonly HttpClientTransportOptions _options;
private readonly McpHttpClient _mcpHttpClient;
private readonly ILoggerFactory? _loggerFactory;
private readonly HttpClient? _ownedHttpClient;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
/// Configuration options for the transport.
/// Logger factory for creating loggers used for diagnostic output during transport operations.
- public SseClientTransport(SseClientTransportOptions transportOptions, ILoggerFactory? loggerFactory = null)
+ public HttpClientTransport(HttpClientTransportOptions transportOptions, ILoggerFactory? loggerFactory = null)
: this(transportOptions, new HttpClient(), loggerFactory, ownsHttpClient: true)
{
}
///
- /// Initializes a new instance of the class with a provided HTTP client.
+ /// Initializes a new instance of the class with a provided HTTP client.
///
/// Configuration options for the transport.
/// The HTTP client instance used for requests.
@@ -41,7 +41,7 @@ public SseClientTransport(SseClientTransportOptions transportOptions, ILoggerFac
/// to dispose of when the transport is disposed;
/// if the caller is retaining ownership of the 's lifetime.
///
- public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
+ public HttpClientTransport(HttpClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
{
Throw.IfNull(transportOptions);
Throw.IfNull(httpClient);
diff --git a/src/ModelContextProtocol.Core/Client/SseClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs
similarity index 96%
rename from src/ModelContextProtocol.Core/Client/SseClientTransportOptions.cs
rename to src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs
index 4097844cf..94b95eecb 100644
--- a/src/ModelContextProtocol.Core/Client/SseClientTransportOptions.cs
+++ b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs
@@ -3,9 +3,9 @@
namespace ModelContextProtocol.Client;
///
-/// Provides options for configuring instances.
+/// Provides options for configuring instances.
///
-public sealed class SseClientTransportOptions
+public sealed class HttpClientTransportOptions
{
///
/// Gets or sets the base address of the server for SSE connections.
diff --git a/src/ModelContextProtocol.Core/Client/IClientTransport.cs b/src/ModelContextProtocol.Core/Client/IClientTransport.cs
index 525178957..2201e9b4f 100644
--- a/src/ModelContextProtocol.Core/Client/IClientTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/IClientTransport.cs
@@ -11,7 +11,7 @@ namespace ModelContextProtocol.Client;
/// and servers, allowing different transport protocols to be used interchangeably.
///
///
-/// When creating an , is typically used, and is
+/// When creating an , is typically used, and is
/// provided with the based on expected server configuration.
///
///
@@ -39,7 +39,7 @@ public interface IClientTransport
/// the transport session as well.
///
///
- /// This method is used by to initialize the connection.
+ /// This method is used by to initialize the connection.
///
///
/// The transport connection could not be established.
diff --git a/src/ModelContextProtocol.Core/Client/IMcpClient.cs b/src/ModelContextProtocol.Core/Client/IMcpClient.cs
index 68a92a2d9..141add86a 100644
--- a/src/ModelContextProtocol.Core/Client/IMcpClient.cs
+++ b/src/ModelContextProtocol.Core/Client/IMcpClient.cs
@@ -1,10 +1,13 @@
-using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Protocol;
+using System.ComponentModel;
namespace ModelContextProtocol.Client;
///
/// Represents an instance of a Model Context Protocol (MCP) client that connects to and communicates with an MCP server.
///
+[Obsolete($"Use {nameof(McpClient)} instead. This member will be removed in a subsequent release.")] // See: https://github.com/modelcontextprotocol/csharp-sdk/issues/774
+[EditorBrowsable(EditorBrowsableState.Never)]
public interface IMcpClient : IMcpEndpoint
{
///
@@ -44,4 +47,4 @@ public interface IMcpClient : IMcpEndpoint
///
///
string? ServerInstructions { get; }
-}
\ No newline at end of file
+}
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
new file mode 100644
index 000000000..5550e786e
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -0,0 +1,713 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Client;
+
+///
+/// Represents an instance of a Model Context Protocol (MCP) client session that connects to and communicates with an MCP server.
+///
+#pragma warning disable CS0618 // Type or member is obsolete
+public abstract partial class McpClient : McpSession, IMcpClient
+#pragma warning restore CS0618 // Type or member is obsolete
+{
+ /// Creates an , connecting it to the specified server.
+ /// The transport instance used to communicate with the server.
+ ///
+ /// A client configuration object which specifies client capabilities and protocol version.
+ /// If , details based on the current process will be employed.
+ ///
+ /// A logger factory for creating loggers for clients.
+ /// The to monitor for cancellation requests. The default is .
+ /// An that's connected to the specified server.
+ /// is .
+ /// is .
+ public static async Task CreateAsync(
+ IClientTransport clientTransport,
+ McpClientOptions? clientOptions = null,
+ ILoggerFactory? loggerFactory = null,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(clientTransport);
+
+ var transport = await clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
+ var endpointName = clientTransport.Name;
+
+ var clientSession = new McpClientImpl(transport, endpointName, clientOptions, loggerFactory);
+ try
+ {
+ await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ await clientSession.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+
+ return clientSession;
+ }
+
+ ///
+ /// Sends a ping request to verify server connectivity.
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that completes when the ping is successful.
+ /// Thrown when the server cannot be reached or returns an error response.
+ public Task PingAsync(CancellationToken cancellationToken = default)
+ {
+ var opts = McpJsonUtilities.DefaultOptions;
+ opts.MakeReadOnly();
+ return SendRequestAsync