Skip to content

Commit 973c7c0

Browse files
halter73eiriktsarpalisjeffhandley
authored
Add ModelContextProtocol.AspNetCore (#160)
* Add ModelContextProtocol.AspNetCore - Remove IServerTransport and HttpListenerSseServerTransport - Replace HttpListenerSseServerTransport usage in tests with ASP.NET Core. Co-authored-by: Eirik Tsarpalis <[email protected]> Co-authored-by: Jeff Handley <[email protected]>
1 parent 330e526 commit 973c7c0

30 files changed

+332
-831
lines changed

Diff for: ModelContextProtocol.sln

+7
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickstartWeatherServer", "
5050
EndProject
5151
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickstartClient", "samples\QuickstartClient\QuickstartClient.csproj", "{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}"
5252
EndProject
53+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore", "src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj", "{37B6A5E0-9995-497D-8B43-3BC6870CC716}"
54+
EndProject
5355
Global
5456
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5557
Debug|Any CPU = Debug|Any CPU
@@ -92,6 +94,10 @@ Global
9294
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
9395
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
9496
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA}.Release|Any CPU.Build.0 = Release|Any CPU
97+
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
98+
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Debug|Any CPU.Build.0 = Debug|Any CPU
99+
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.ActiveCfg = Release|Any CPU
100+
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.Build.0 = Release|Any CPU
95101
EndGlobalSection
96102
GlobalSection(SolutionProperties) = preSolution
97103
HideSolutionNode = FALSE
@@ -107,6 +113,7 @@ Global
107113
{0C6D0512-D26D-63D3-5019-C5F7A657B28C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
108114
{4653EB0C-8FC0-98F4-E9C8-220EDA7A69DF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
109115
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
116+
{37B6A5E0-9995-497D-8B43-3BC6870CC716} = {A2F1F52A-9107-4BF8-8C3F-2F6670E7D0AD}
110117
EndGlobalSection
111118
GlobalSection(ExtensibilityGlobals) = postSolution
112119
SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89}

Diff for: samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
<ItemGroup>
1010
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
11+
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
1112
</ItemGroup>
1213

1314
</Project>

Diff for: samples/AspNetCoreSseServer/McpEndpointRouteBuilderExtensions.cs

-62
This file was deleted.

Diff for: samples/AspNetCoreSseServer/Program.cs

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
using AspNetCoreSseServer;
1+
using ModelContextProtocol.AspNetCore;
22

33
var builder = WebApplication.CreateBuilder(args);
44
builder.Services.AddMcpServer().WithToolsFromAssembly();
55
var app = builder.Build();
66

7-
app.MapGet("/", () => "Hello World!");
8-
app.MapMcpSse();
7+
app.MapMcp();
98

109
app.Run();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.AspNetCore.Routing;
4+
using Microsoft.AspNetCore.WebUtilities;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Options;
8+
using ModelContextProtocol.Protocol.Messages;
9+
using ModelContextProtocol.Protocol.Transport;
10+
using ModelContextProtocol.Server;
11+
using ModelContextProtocol.Utils.Json;
12+
using System.Collections.Concurrent;
13+
using System.Security.Cryptography;
14+
15+
namespace ModelContextProtocol.AspNetCore;
16+
17+
/// <summary>
18+
/// Extension methods for <see cref="IEndpointRouteBuilder"/> to add MCP endpoints.
19+
/// </summary>
20+
public static class McpEndpointRouteBuilderExtensions
21+
{
22+
/// <summary>
23+
/// Sets up endpoints for handling MCP HTTP Streaming transport.
24+
/// </summary>
25+
/// <param name="endpoints">The web application to attach MCP HTTP endpoints.</param>
26+
/// <param name="runSession">Provides an optional asynchronous callback for handling new MCP sessions.</param>
27+
/// <returns>Returns a builder for configuring additional endpoint conventions like authorization policies.</returns>
28+
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, Func<HttpContext, IMcpServer, CancellationToken, Task>? runSession = null)
29+
{
30+
ConcurrentDictionary<string, SseResponseStreamTransport> _sessions = new(StringComparer.Ordinal);
31+
32+
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
33+
var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
34+
35+
var routeGroup = endpoints.MapGroup("");
36+
37+
routeGroup.MapGet("/sse", async context =>
38+
{
39+
var response = context.Response;
40+
var requestAborted = context.RequestAborted;
41+
42+
response.Headers.ContentType = "text/event-stream";
43+
response.Headers.CacheControl = "no-cache";
44+
45+
var sessionId = MakeNewSessionId();
46+
await using var transport = new SseResponseStreamTransport(response.Body, $"/message?sessionId={sessionId}");
47+
if (!_sessions.TryAdd(sessionId, transport))
48+
{
49+
throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
50+
}
51+
await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);
52+
53+
try
54+
{
55+
var transportTask = transport.RunAsync(cancellationToken: requestAborted);
56+
runSession ??= RunSession;
57+
58+
try
59+
{
60+
await runSession(context, server, requestAborted);
61+
}
62+
finally
63+
{
64+
await transport.DisposeAsync();
65+
await transportTask;
66+
}
67+
}
68+
catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
69+
{
70+
// RequestAborted always triggers when the client disconnects before a complete response body is written,
71+
// but this is how SSE connections are typically closed.
72+
}
73+
finally
74+
{
75+
_sessions.TryRemove(sessionId, out _);
76+
}
77+
});
78+
79+
routeGroup.MapPost("/message", async context =>
80+
{
81+
if (!context.Request.Query.TryGetValue("sessionId", out var sessionId))
82+
{
83+
await Results.BadRequest("Missing sessionId query parameter.").ExecuteAsync(context);
84+
return;
85+
}
86+
87+
if (!_sessions.TryGetValue(sessionId.ToString(), out var transport))
88+
{
89+
await Results.BadRequest($"Session {sessionId} not found.").ExecuteAsync(context);
90+
return;
91+
}
92+
93+
var message = await context.Request.ReadFromJsonAsync<IJsonRpcMessage>(McpJsonUtilities.DefaultOptions, context.RequestAborted);
94+
if (message is null)
95+
{
96+
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
97+
return;
98+
}
99+
100+
await transport.OnMessageReceivedAsync(message, context.RequestAborted);
101+
context.Response.StatusCode = StatusCodes.Status202Accepted;
102+
await context.Response.WriteAsync("Accepted");
103+
});
104+
105+
return routeGroup;
106+
}
107+
108+
private static Task RunSession(HttpContext httpContext, IMcpServer session, CancellationToken requestAborted)
109+
=> session.RunAsync(requestAborted);
110+
111+
private static string MakeNewSessionId()
112+
{
113+
// 128 bits
114+
Span<byte> buffer = stackalloc byte[16];
115+
RandomNumberGenerator.Fill(buffer);
116+
return WebEncoders.Base64UrlEncode(buffer);
117+
}
118+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
8+
<IsPackable>true</IsPackable>
9+
<PackageId>ModelContextProtocol.AspNetCore</PackageId>
10+
<Description>ASP.NET Core extensions for the C# Model Context Protocol (MCP) SDK.</Description>
11+
<PackageReadmeFile>README.md</PackageReadmeFile>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<None Include="README.md" pack="true" PackagePath="\" />
20+
</ItemGroup>
21+
<ItemGroup>
22+
<ProjectReference Include="..\ModelContextProtocol\ModelContextProtocol.csproj" />
23+
</ItemGroup>
24+
25+
</Project>

Diff for: src/ModelContextProtocol.AspNetCore/README.md

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# ASP.NET Core extensions for the MCP C# SDK
2+
3+
[![NuGet preview version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol/absoluteLatest)
4+
5+
The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit our [API documentation](https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.html) for more details on available functionality.
6+
7+
> [!NOTE]
8+
> This project is in preview; breaking changes can be introduced without prior notice.
9+
10+
## About MCP
11+
12+
The Model Context Protocol (MCP) is an open protocol that standardizes how applications provide context to Large Language Models (LLMs). It enables secure integration between LLMs and various data sources and tools.
13+
14+
For more information about MCP:
15+
16+
- [Official Documentation](https://modelcontextprotocol.io/)
17+
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
18+
- [GitHub Organization](https://github.com/modelcontextprotocol)
19+
20+
## Installation
21+
22+
To get started, install the package from NuGet
23+
24+
```
25+
dotnet new web
26+
dotnet add package ModelContextProtocol.AspNetcore --prerelease
27+
```
28+
29+
## Getting Started
30+
31+
```csharp
32+
// Program.cs
33+
using ModelContextProtocol;
34+
using ModelContextProtocol.AspNetCore;
35+
36+
var builder = WebApplication.CreateBuilder(args);
37+
builder.WebHost.ConfigureKestrel(options =>
38+
{
39+
options.ListenLocalhost(3001);
40+
});
41+
builder.Services.AddMcpServer().WithToolsFromAssembly();
42+
var app = builder.Build();
43+
44+
app.MapMcp();
45+
46+
app.Run();
47+
48+
[McpServerToolType]
49+
public static class EchoTool
50+
{
51+
[McpServerTool, Description("Echoes the message back to the client.")]
52+
public static string Echo(string message) => $"hello {message}";
53+
}
54+
```

Diff for: src/ModelContextProtocol/Client/McpClient.cs

+7-9
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,9 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
7979
{
8080
// Connect transport
8181
_sessionTransport = await _clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
82-
InitializeSession(_sessionTransport);
8382
// We don't want the ConnectAsync token to cancel the session after we've successfully connected.
8483
// The base class handles cleaning up the session in DisposeAsync without our help.
85-
StartSession(fullSessionCancellationToken: CancellationToken.None);
84+
StartSession(_sessionTransport, fullSessionCancellationToken: CancellationToken.None);
8685

8786
// Perform initialization sequence
8887
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -142,13 +141,14 @@ await SendMessageAsync(
142141
/// <inheritdoc/>
143142
public override async ValueTask DisposeUnsynchronizedAsync()
144143
{
145-
if (_connectCts is not null)
146-
{
147-
await _connectCts.CancelAsync().ConfigureAwait(false);
148-
}
149-
150144
try
151145
{
146+
if (_connectCts is not null)
147+
{
148+
await _connectCts.CancelAsync().ConfigureAwait(false);
149+
_connectCts.Dispose();
150+
}
151+
152152
await base.DisposeUnsynchronizedAsync().ConfigureAwait(false);
153153
}
154154
finally
@@ -157,8 +157,6 @@ public override async ValueTask DisposeUnsynchronizedAsync()
157157
{
158158
await _sessionTransport.DisposeAsync().ConfigureAwait(false);
159159
}
160-
161-
_connectCts?.Dispose();
162160
}
163161
}
164162
}

Diff for: src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs

+1-14
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ public static IMcpServerBuilder WithStdioServerTransport(this IMcpServerBuilder
337337
Throw.IfNull(builder);
338338

339339
builder.Services.AddSingleton<ITransport, StdioServerTransport>();
340-
builder.Services.AddHostedService<McpServerSingleSessionHostedService>();
340+
builder.Services.AddHostedService<StdioMcpServerHostedService>();
341341

342342
builder.Services.AddSingleton(services =>
343343
{
@@ -350,18 +350,5 @@ public static IMcpServerBuilder WithStdioServerTransport(this IMcpServerBuilder
350350

351351
return builder;
352352
}
353-
354-
/// <summary>
355-
/// Adds a server transport that uses SSE via a HttpListener for communication.
356-
/// </summary>
357-
/// <param name="builder">The builder instance.</param>
358-
public static IMcpServerBuilder WithHttpListenerSseServerTransport(this IMcpServerBuilder builder)
359-
{
360-
Throw.IfNull(builder);
361-
362-
builder.Services.AddSingleton<IServerTransport, HttpListenerSseServerTransport>();
363-
builder.Services.AddHostedService<McpServerMultiSessionHostedService>();
364-
return builder;
365-
}
366353
#endregion
367354
}

0 commit comments

Comments
 (0)