Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<Project Path="docs/concepts/progress/samples/server/Progress.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AspNetCoreMcpClient/AspNetCoreMcpClient.csproj" />
<Project Path="samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj" />
<Project Path="samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj" />
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
Expand Down Expand Up @@ -73,6 +74,7 @@
<Project Path="src/ModelContextProtocol/ModelContextProtocol.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AspNetCoreMcpClient.Tests/AspNetCoreMcpClient.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj" />
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
Expand Down
13 changes: 13 additions & 0 deletions samples/AspNetCoreMcpClient/AspNetCoreMcpClient.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
</ItemGroup>

</Project>
108 changes: 108 additions & 0 deletions samples/AspNetCoreMcpClient/ElicitationBroker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using ModelContextProtocol.Protocol;

namespace AspNetCoreMcpClient;

/// <summary>
/// Bridges an MCP elicitation request to an application-owned HTTP interaction.
/// </summary>
public sealed class ElicitationBroker
{
private readonly Dictionary<string, PendingRequest> _pending = new(StringComparer.Ordinal);
private readonly Lock _lock = new();

public async ValueTask<ElicitResult> RequestAsync(
string sessionId,
ElicitRequestParams? request,
CancellationToken cancellationToken)
{
var pending = new PendingRequest(Guid.NewGuid(), request);

lock (_lock)
{
if (!_pending.TryAdd(sessionId, pending))
{
throw new InvalidOperationException("Only one elicitation can be pending for an application session.");
}
}

try
{
return await pending.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
lock (_lock)
{
if (_pending.TryGetValue(sessionId, out var current) && ReferenceEquals(current, pending))
{
_pending.Remove(sessionId);
}
}
}
}

public PendingElicitation? GetPending(string sessionId)
{
lock (_lock)
{
return _pending.TryGetValue(sessionId, out var pending)
? new PendingElicitation(pending.Id, pending.Request)
: null;
}
}

public bool TryRespond(string sessionId, Guid requestId, ElicitResult response)
{
ArgumentNullException.ThrowIfNull(response);

lock (_lock)
{
return _pending.TryGetValue(sessionId, out var pending) &&
pending.Id == requestId &&
pending.Completion.TrySetResult(response);
}
}

public void Cancel(string sessionId)
{
lock (_lock)
{
if (_pending.Remove(sessionId, out var pending))
{
pending.Completion.TrySetCanceled();
}
}
}

/// <summary>
/// Cancels every pending elicitation. Call this during application shutdown so that MCP operations blocked on an
/// unanswered elicitation unblock and the session registry can dispose its clients.
/// </summary>
public int CancelAll()
{
lock (_lock)
{
var pending = _pending.Values.ToArray();
_pending.Clear();

foreach (var request in pending)
{
request.Completion.TrySetCanceled();
}

return pending.Length;
}
}

private sealed class PendingRequest(Guid id, ElicitRequestParams? request)
{
public Guid Id { get; } = id;

public ElicitRequestParams? Request { get; } = request;

public TaskCompletionSource<ElicitResult> Completion { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}

public sealed record PendingElicitation(Guid Id, ElicitRequestParams? Request);
6 changes: 6 additions & 0 deletions samples/AspNetCoreMcpClient/InlineProgress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AspNetCoreMcpClient;

public sealed class InlineProgress<T>(Action<T> report) : IProgress<T>
{
public void Report(T value) => report(value);
}
22 changes: 22 additions & 0 deletions samples/AspNetCoreMcpClient/McpClientCleanupService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace AspNetCoreMcpClient;

public sealed class McpClientCleanupService(
SessionClientRegistry<McpClientConnection> registry,
IConfiguration configuration,
ILogger<McpClientCleanupService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMinutes(configuration.GetValue("McpServer:CleanupIntervalMinutes", 1));
using var timer = new PeriodicTimer(interval);

while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
var removed = await registry.RemoveIdleAsync().ConfigureAwait(false);
if (removed > 0)
{
logger.LogInformation("Disposed {Count} idle MCP client sessions.", removed);
}
}
}
}
20 changes: 20 additions & 0 deletions samples/AspNetCoreMcpClient/McpClientConnection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using ModelContextProtocol.Client;

namespace AspNetCoreMcpClient;

public sealed class McpClientConnection(McpClient client, HttpClientTransport transport) : IAsyncDisposable
{
public McpClient Client { get; } = client;

public async ValueTask DisposeAsync()
{
try
{
await Client.DisposeAsync().ConfigureAwait(false);
}
finally
{
await transport.DisposeAsync().ConfigureAwait(false);
}
}
}
160 changes: 160 additions & 0 deletions samples/AspNetCoreMcpClient/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using AspNetCoreMcpClient;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);

var endpoint = new Uri(builder.Configuration["McpServer:Endpoint"] ?? "http://localhost:3001");
var idleTimeout = TimeSpan.FromMinutes(builder.Configuration.GetValue("McpServer:IdleTimeoutMinutes", 20));

builder.Services.AddHttpClient("mcp-server");
builder.Services.AddSingleton<ElicitationBroker>();
builder.Services.AddSingleton(serviceProvider =>
{
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var elicitationBroker = serviceProvider.GetRequiredService<ElicitationBroker>();

return new SessionClientRegistry<McpClientConnection>(
async (sessionId, cancellationToken) =>
{
var httpClient = httpClientFactory.CreateClient("mcp-server");
var transport = new HttpClientTransport(
new()
{
Endpoint = endpoint,
Name = $"ASP.NET Core session {sessionId}",
TransportMode = HttpTransportMode.StreamableHttp,
},
httpClient,
loggerFactory,
ownsHttpClient: true);

try
{
var client = await McpClient.CreateAsync(
transport,
new()
{
ClientInfo = new() { Name = "AspNetCoreMcpClient", Version = "1.0.0" },
Handlers = new()
{
ElicitationHandler = (request, token) =>
elicitationBroker.RequestAsync(sessionId, request, token),
},
},
loggerFactory,
cancellationToken);

return new McpClientConnection(client, transport);
}
catch
{
await transport.DisposeAsync();
throw;
}
},
TimeProvider.System,
idleTimeout);
});
builder.Services.AddHostedService<McpClientCleanupService>();

var app = builder.Build();

// Disposing the registry waits for each session's in-flight operation to finish. A tool call blocked on an unanswered
// elicitation would never finish, so release those requests before shutdown disposes the singleton registry.
app.Lifetime.ApplicationStopping.Register(() =>
{
var broker = app.Services.GetRequiredService<ElicitationBroker>();
var canceled = broker.CancelAll();
if (canceled > 0)
{
app.Logger.LogInformation("Canceled {Count} pending elicitations during shutdown.", canceled);
}
});

app.MapGet("/tools", async (
HttpContext context,
SessionClientRegistry<McpClientConnection> registry,
CancellationToken cancellationToken) =>
{
var sessionId = GetDemoSessionId(context);
var tools = await registry.ExecuteAsync(
sessionId,
async (connection, token) => await connection.Client.ListToolsAsync(cancellationToken: token),
cancellationToken);

return tools.Select(tool => new { tool.Name, tool.Description });
});

app.MapPost("/tools/{toolName}", async (
string toolName,
JsonElement? arguments,
HttpContext context,
SessionClientRegistry<McpClientConnection> registry,
CancellationToken cancellationToken) =>
{
var sessionId = GetDemoSessionId(context);
var toolArguments = arguments is { ValueKind: JsonValueKind.Object }
? arguments.Value.Deserialize<Dictionary<string, object?>>()
: null;

// Progress notifications arrive on the MCP session's message loop, so use a thread-safe collection.
var progressUpdates = new ConcurrentQueue<ProgressNotificationValue>();
var progress = new InlineProgress<ProgressNotificationValue>(progressUpdates.Enqueue);
var result = await registry.ExecuteAsync(
sessionId,
async (connection, token) => await connection.Client.CallToolAsync(
toolName,
toolArguments,
progress,
cancellationToken: token),
cancellationToken);

return Results.Ok(new { Result = result, Progress = progressUpdates });
});

app.MapGet("/elicitation", (HttpContext context, ElicitationBroker broker) =>
{
var pending = broker.GetPending(GetDemoSessionId(context));
return pending is null ? Results.NoContent() : Results.Ok(pending);
});

app.MapPost("/elicitation/{requestId:guid}", (
Guid requestId,
ElicitResult response,
HttpContext context,
ElicitationBroker broker) =>
{
return broker.TryRespond(GetDemoSessionId(context), requestId, response)
? Results.Accepted()
: Results.NotFound();
});

app.MapDelete("/session", async (
HttpContext context,
SessionClientRegistry<McpClientConnection> registry,
ElicitationBroker broker) =>
{
var sessionId = GetDemoSessionId(context);
broker.Cancel(sessionId);
return await registry.RemoveAsync(sessionId) ? Results.NoContent() : Results.NotFound();
});

app.Run();

static string GetDemoSessionId(HttpContext context)
{
const string HeaderName = "X-Demo-User";
var sessionId = context.Request.Headers[HeaderName].ToString();
if (string.IsNullOrWhiteSpace(sessionId) || sessionId.Length > 128)
{
throw new BadHttpRequestException($"Provide a non-empty {HeaderName} header of at most 128 characters.");
}

// A production application should derive this key from authenticated server-side identity/session state.
return sessionId;
}
Loading