Skip to content

Commit 6a69512

Browse files
committed
Add ASP.NET Core per-user MCP client sample
1 parent 81ae6ec commit 6a69512

14 files changed

Lines changed: 730 additions & 0 deletions

ModelContextProtocol.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<Project Path="docs/concepts/progress/samples/server/Progress.csproj" />
4141
</Folder>
4242
<Folder Name="/samples/">
43+
<Project Path="samples/AspNetCoreMcpClient/AspNetCoreMcpClient.csproj" />
4344
<Project Path="samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj" />
4445
<Project Path="samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj" />
4546
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
@@ -73,6 +74,7 @@
7374
<Project Path="src/ModelContextProtocol/ModelContextProtocol.csproj" />
7475
</Folder>
7576
<Folder Name="/tests/">
77+
<Project Path="tests/AspNetCoreMcpClient.Tests/AspNetCoreMcpClient.Tests.csproj" />
7678
<Project Path="tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj" />
7779
<Project Path="tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj" />
7880
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
11+
</ItemGroup>
12+
13+
</Project>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using ModelContextProtocol.Protocol;
2+
3+
namespace AspNetCoreMcpClient;
4+
5+
/// <summary>
6+
/// Bridges an MCP elicitation request to an application-owned HTTP interaction.
7+
/// </summary>
8+
public sealed class ElicitationBroker
9+
{
10+
private readonly Dictionary<string, PendingRequest> _pending = new(StringComparer.Ordinal);
11+
private readonly Lock _lock = new();
12+
13+
public async ValueTask<ElicitResult> RequestAsync(
14+
string sessionId,
15+
ElicitRequestParams? request,
16+
CancellationToken cancellationToken)
17+
{
18+
var pending = new PendingRequest(Guid.NewGuid(), request);
19+
20+
lock (_lock)
21+
{
22+
if (!_pending.TryAdd(sessionId, pending))
23+
{
24+
throw new InvalidOperationException("Only one elicitation can be pending for an application session.");
25+
}
26+
}
27+
28+
try
29+
{
30+
return await pending.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
31+
}
32+
finally
33+
{
34+
lock (_lock)
35+
{
36+
if (_pending.TryGetValue(sessionId, out var current) && ReferenceEquals(current, pending))
37+
{
38+
_pending.Remove(sessionId);
39+
}
40+
}
41+
}
42+
}
43+
44+
public PendingElicitation? GetPending(string sessionId)
45+
{
46+
lock (_lock)
47+
{
48+
return _pending.TryGetValue(sessionId, out var pending)
49+
? new PendingElicitation(pending.Id, pending.Request)
50+
: null;
51+
}
52+
}
53+
54+
public bool TryRespond(string sessionId, Guid requestId, ElicitResult response)
55+
{
56+
ArgumentNullException.ThrowIfNull(response);
57+
58+
lock (_lock)
59+
{
60+
return _pending.TryGetValue(sessionId, out var pending) &&
61+
pending.Id == requestId &&
62+
pending.Completion.TrySetResult(response);
63+
}
64+
}
65+
66+
public void Cancel(string sessionId)
67+
{
68+
lock (_lock)
69+
{
70+
if (_pending.Remove(sessionId, out var pending))
71+
{
72+
pending.Completion.TrySetCanceled();
73+
}
74+
}
75+
}
76+
77+
private sealed class PendingRequest(Guid id, ElicitRequestParams? request)
78+
{
79+
public Guid Id { get; } = id;
80+
81+
public ElicitRequestParams? Request { get; } = request;
82+
83+
public TaskCompletionSource<ElicitResult> Completion { get; } =
84+
new(TaskCreationOptions.RunContinuationsAsynchronously);
85+
}
86+
}
87+
88+
public sealed record PendingElicitation(Guid Id, ElicitRequestParams? Request);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace AspNetCoreMcpClient;
2+
3+
public sealed class InlineProgress<T>(Action<T> report) : IProgress<T>
4+
{
5+
public void Report(T value) => report(value);
6+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace AspNetCoreMcpClient;
2+
3+
public sealed class McpClientCleanupService(
4+
SessionClientRegistry<McpClientConnection> registry,
5+
IConfiguration configuration,
6+
ILogger<McpClientCleanupService> logger) : BackgroundService
7+
{
8+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
9+
{
10+
var interval = TimeSpan.FromMinutes(configuration.GetValue("McpServer:CleanupIntervalMinutes", 1));
11+
using var timer = new PeriodicTimer(interval);
12+
13+
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
14+
{
15+
var removed = await registry.RemoveIdleAsync().ConfigureAwait(false);
16+
if (removed > 0)
17+
{
18+
logger.LogInformation("Disposed {Count} idle MCP client sessions.", removed);
19+
}
20+
}
21+
}
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using ModelContextProtocol.Client;
2+
3+
namespace AspNetCoreMcpClient;
4+
5+
public sealed class McpClientConnection(McpClient client, HttpClientTransport transport) : IAsyncDisposable
6+
{
7+
public McpClient Client { get; } = client;
8+
9+
public async ValueTask DisposeAsync()
10+
{
11+
try
12+
{
13+
await Client.DisposeAsync().ConfigureAwait(false);
14+
}
15+
finally
16+
{
17+
await transport.DisposeAsync().ConfigureAwait(false);
18+
}
19+
}
20+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using AspNetCoreMcpClient;
2+
using ModelContextProtocol;
3+
using ModelContextProtocol.Client;
4+
using ModelContextProtocol.Protocol;
5+
using System.Text.Json;
6+
7+
var builder = WebApplication.CreateBuilder(args);
8+
9+
var endpoint = new Uri(builder.Configuration["McpServer:Endpoint"] ?? "http://localhost:3001");
10+
var idleTimeout = TimeSpan.FromMinutes(builder.Configuration.GetValue("McpServer:IdleTimeoutMinutes", 20));
11+
12+
builder.Services.AddHttpClient("mcp-server");
13+
builder.Services.AddSingleton<ElicitationBroker>();
14+
builder.Services.AddSingleton(serviceProvider =>
15+
{
16+
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
17+
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
18+
var elicitationBroker = serviceProvider.GetRequiredService<ElicitationBroker>();
19+
20+
return new SessionClientRegistry<McpClientConnection>(
21+
async (sessionId, cancellationToken) =>
22+
{
23+
var httpClient = httpClientFactory.CreateClient("mcp-server");
24+
var transport = new HttpClientTransport(
25+
new()
26+
{
27+
Endpoint = endpoint,
28+
Name = $"ASP.NET Core session {sessionId}",
29+
TransportMode = HttpTransportMode.StreamableHttp,
30+
},
31+
httpClient,
32+
loggerFactory,
33+
ownsHttpClient: true);
34+
35+
try
36+
{
37+
var client = await McpClient.CreateAsync(
38+
transport,
39+
new()
40+
{
41+
ClientInfo = new() { Name = "AspNetCoreMcpClient", Version = "1.0.0" },
42+
Handlers = new()
43+
{
44+
ElicitationHandler = (request, token) =>
45+
elicitationBroker.RequestAsync(sessionId, request, token),
46+
},
47+
},
48+
loggerFactory,
49+
cancellationToken);
50+
51+
return new McpClientConnection(client, transport);
52+
}
53+
catch
54+
{
55+
await transport.DisposeAsync();
56+
throw;
57+
}
58+
},
59+
TimeProvider.System,
60+
idleTimeout);
61+
});
62+
builder.Services.AddHostedService<McpClientCleanupService>();
63+
64+
var app = builder.Build();
65+
66+
app.MapGet("/tools", async (
67+
HttpContext context,
68+
SessionClientRegistry<McpClientConnection> registry,
69+
CancellationToken cancellationToken) =>
70+
{
71+
var sessionId = GetDemoSessionId(context);
72+
var tools = await registry.ExecuteAsync(
73+
sessionId,
74+
async (connection, token) => await connection.Client.ListToolsAsync(cancellationToken: token),
75+
cancellationToken);
76+
77+
return tools.Select(tool => new { tool.Name, tool.Description });
78+
});
79+
80+
app.MapPost("/tools/{toolName}", async (
81+
string toolName,
82+
JsonElement? arguments,
83+
HttpContext context,
84+
SessionClientRegistry<McpClientConnection> registry,
85+
CancellationToken cancellationToken) =>
86+
{
87+
var sessionId = GetDemoSessionId(context);
88+
var toolArguments = arguments is { ValueKind: JsonValueKind.Object }
89+
? arguments.Value.Deserialize<Dictionary<string, object?>>()
90+
: null;
91+
92+
var progressUpdates = new List<ProgressNotificationValue>();
93+
var progress = new InlineProgress<ProgressNotificationValue>(value => progressUpdates.Add(value));
94+
var result = await registry.ExecuteAsync(
95+
sessionId,
96+
async (connection, token) => await connection.Client.CallToolAsync(
97+
toolName,
98+
toolArguments,
99+
progress,
100+
cancellationToken: token),
101+
cancellationToken);
102+
103+
return Results.Ok(new { Result = result, Progress = progressUpdates });
104+
});
105+
106+
app.MapGet("/elicitation", (HttpContext context, ElicitationBroker broker) =>
107+
{
108+
var pending = broker.GetPending(GetDemoSessionId(context));
109+
return pending is null ? Results.NoContent() : Results.Ok(pending);
110+
});
111+
112+
app.MapPost("/elicitation/{requestId:guid}", (
113+
Guid requestId,
114+
ElicitResult response,
115+
HttpContext context,
116+
ElicitationBroker broker) =>
117+
{
118+
return broker.TryRespond(GetDemoSessionId(context), requestId, response)
119+
? Results.Accepted()
120+
: Results.NotFound();
121+
});
122+
123+
app.MapDelete("/session", async (
124+
HttpContext context,
125+
SessionClientRegistry<McpClientConnection> registry,
126+
ElicitationBroker broker) =>
127+
{
128+
var sessionId = GetDemoSessionId(context);
129+
broker.Cancel(sessionId);
130+
return await registry.RemoveAsync(sessionId) ? Results.NoContent() : Results.NotFound();
131+
});
132+
133+
app.Run();
134+
135+
static string GetDemoSessionId(HttpContext context)
136+
{
137+
const string HeaderName = "X-Demo-User";
138+
var sessionId = context.Request.Headers[HeaderName].ToString();
139+
if (string.IsNullOrWhiteSpace(sessionId) || sessionId.Length > 128)
140+
{
141+
throw new BadHttpRequestException($"Provide a non-empty {HeaderName} header of at most 128 characters.");
142+
}
143+
144+
// A production application should derive this key from authenticated server-side identity/session state.
145+
return sessionId;
146+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# ASP.NET Core MCP client with per-user sessions
2+
3+
This sample shows an ASP.NET Core Web API acting as an MCP client while keeping one MCP connection per application user session. It is intended for applications that need session continuity for server-to-client features such as elicitation and progress notifications.
4+
5+
The sample deliberately separates the **application session** from the MCP protocol session. `SessionClientRegistry<TClient>` owns the mapping and provides:
6+
7+
- lazy, single initialization of a client for each application session;
8+
- serialization of operations within one session while allowing different sessions to run concurrently;
9+
- explicit session removal and deterministic asynchronous disposal;
10+
- automatic removal of idle sessions; and
11+
- cleanup of every remaining client during application shutdown.
12+
13+
## Run the sample
14+
15+
Start an HTTP MCP server, such as `AspNetCoreMcpServer`, then run this project:
16+
17+
```bash
18+
dotnet run --project samples/AspNetCoreMcpServer
19+
dotnet run --project samples/AspNetCoreMcpClient
20+
```
21+
22+
The default MCP endpoint is `http://localhost:3001`. Change `McpServer:Endpoint` in `appsettings.json` when needed.
23+
24+
The HTTP examples use `X-Demo-User` solely to make session reuse visible without adding an authentication system:
25+
26+
```bash
27+
curl -H "X-Demo-User: alice" http://localhost:5000/tools
28+
29+
curl -X POST \
30+
-H "Content-Type: application/json" \
31+
-H "X-Demo-User: alice" \
32+
-d '{"message":"hello"}' \
33+
http://localhost:5000/tools/echo
34+
35+
curl -X DELETE -H "X-Demo-User: alice" http://localhost:5000/session
36+
```
37+
38+
Use the URL printed by `dotnet run` if it differs from port 5000.
39+
40+
## Elicitation flow
41+
42+
When the MCP server sends an elicitation request during a tool call, `ElicitationBroker` holds that request while the application's frontend collects an answer:
43+
44+
1. The frontend polls `GET /elicitation` with the same application-session identity.
45+
2. A `200` response contains the pending request and its ID; `204` means there is no pending request.
46+
3. The frontend posts an `ElicitResult` to `POST /elicitation/{requestId}`.
47+
4. The original tool call resumes and returns its response.
48+
49+
The broker intentionally permits one pending elicitation per application session because the registry serializes that session's MCP operations.
50+
51+
## Production considerations
52+
53+
- **Never trust a caller-provided session header.** Replace `X-Demo-User` with a key derived from authenticated, server-side identity or session state. Do not use access tokens or other secrets as dictionary keys.
54+
- The registry is in-memory and therefore single-node. For multiple application instances, use sticky routing so one user's requests reach the owning process, or implement distributed ownership and session resumption. A distributed cache alone cannot store a live `McpClient` connection.
55+
- Choose an idle timeout that fits both application behavior and upstream resource limits. Explicitly remove the session at logout when possible.
56+
- Operations are serialized per user to protect application-level session state. If your use case permits concurrent MCP requests, adjust the registry policy rather than creating duplicate clients.
57+
- The sample collects progress updates for a compact response. A real frontend would normally stream them with Server-Sent Events, WebSockets, or another application channel.

0 commit comments

Comments
 (0)