## Summary
Bumps the MCP C# SDK dependency from 1.1.0 to 2.1.0 across all 9
projects and aligns the implementation with the 2.x surface and the
2026-07-28 spec — not just a version bump.
### Compile fixes
- Removed the gateway's `Capabilities.Tasks = null` suppression and its
test: the experimental Tasks API was removed from SDK core in 2.0 (moved
to a separate, wire-incompatible `ModelContextProtocol.Extensions.Tasks`
package).
- Added `ResolveInputRequestsAsync` (new abstract MRTR member on
`McpClient`) overrides to the test doubles. MRTR forwarding is
deliberately out of scope for this PR; the gateway's server reports
`IsMrtrSupported == false`.
- Deleted all `MCPEXP001` pragmas — `ServerCapabilities.Extensions`
(which carries `io.modelcontextprotocol/interceptors`) is stable in 2.x.
`MCPEXP002` subclassing pragmas are kept.
### Spec deprecations (SEP-2577 / SEP-2575)
- The gateway intentionally keeps proxying `logging/setLevel` and
`resources/subscribe` for down-level clients/backends, with targeted
`MCP9005` suppressions.
- On negotiated `2026-07-28` connections the SDK rejects these methods
at the session layer (replaced by per-request `_meta` log levels and
`subscriptions/listen`), so the affected tests now pin `protocolVersion:
"2025-06-18"` through a new fixture parameter — making them genuine
down-level-compatibility tests.
### Defensive hardening
- `Tool.inputSchema` is required and validated on deserialization in
2.x. The gateway's `ListToolsHandler` now fetches the backend
`tools/list` result as raw JSON and runs
`GatewayProxyConfigurator.EnsureToolInputSchemas` before and after the
interceptor chain, coercing missing/invalid schemas to the spec-default
`{"type":"object"}` so down-level backends and schema-stripping
interceptors can't fail the request.
### Tests & docs
- New tests: end-to-end schema-stripping coercion,
`EnsureToolInputSchemas` unit cases, and `2026-07-28` protocol-version
negotiation through the gateway (implicitly exercising
`server/discover`).
- `CLAUDE.md` gains an "SDK 2.x / spec 2026-07-28 notes" section;
`ARCHITECTURE_PHASES.md` no longer lists `Tasks` as a mirrored
capability.
## Test plan
- [x] `dotnet build` — 0 errors, 0 warnings (library, tests, all 7
samples)
- [x] `dotnet test` — 105/105 passing (was 98: −1 removed Tasks test, +8
new)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ola Hungerford <olahungerford@gmail.com>
198 lines
7.6 KiB
C#
198 lines
7.6 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Reflection;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Options;
|
|
using ModelContextProtocol.Interceptors.Protocol;
|
|
using ModelContextProtocol.Interceptors.Server;
|
|
using ModelContextProtocol.Server;
|
|
|
|
namespace ModelContextProtocol.Interceptors;
|
|
|
|
/// <summary>
|
|
/// Extension methods for <see cref="IMcpServerBuilder"/> to register interceptors.
|
|
/// </summary>
|
|
public static class McpServerInterceptorBuilderExtensions
|
|
{
|
|
/// <summary>
|
|
/// Registers interceptors from all methods on <typeparamref name="T"/> that are decorated
|
|
/// with <see cref="McpServerInterceptorAttribute"/>.
|
|
/// </summary>
|
|
public static IMcpServerBuilder WithInterceptors<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] T>(
|
|
this IMcpServerBuilder builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
EnsureSetupRegistered(builder.Services);
|
|
|
|
foreach (var method in typeof(T).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static))
|
|
{
|
|
if (method.GetCustomAttribute<McpServerInterceptorAttribute>() is not null)
|
|
{
|
|
McpServerInterceptor interceptor;
|
|
if (method.IsStatic)
|
|
{
|
|
interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
}
|
|
else
|
|
{
|
|
// For instance methods, we need a factory. Register the type in DI if not already.
|
|
builder.Services.TryAddTransient(typeof(T));
|
|
interceptor = ReflectionMcpServerInterceptor.Create(
|
|
method,
|
|
target: null,
|
|
new McpServerInterceptorCreateOptions { Services = null });
|
|
|
|
// Wrap with instance resolution
|
|
var instanceInterceptor = new DeferredInstanceInterceptor<T>(interceptor, method);
|
|
builder.Services.AddSingleton<McpServerInterceptor>(instanceInterceptor);
|
|
continue;
|
|
}
|
|
|
|
builder.Services.AddSingleton<McpServerInterceptor>(interceptor);
|
|
}
|
|
}
|
|
|
|
return builder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers interceptors from all methods on <typeparamref name="T"/> on the given instance.
|
|
/// </summary>
|
|
public static IMcpServerBuilder WithInterceptors<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] T>(
|
|
this IMcpServerBuilder builder,
|
|
T target) where T : class
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
ArgumentNullException.ThrowIfNull(target);
|
|
|
|
EnsureSetupRegistered(builder.Services);
|
|
|
|
foreach (var method in typeof(T).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static))
|
|
{
|
|
if (method.GetCustomAttribute<McpServerInterceptorAttribute>() is not null)
|
|
{
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, method.IsStatic ? null : target);
|
|
builder.Services.AddSingleton<McpServerInterceptor>(interceptor);
|
|
}
|
|
}
|
|
|
|
return builder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the specified interceptors directly.
|
|
/// </summary>
|
|
public static IMcpServerBuilder WithInterceptors(
|
|
this IMcpServerBuilder builder,
|
|
params McpServerInterceptor[] interceptors)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
EnsureSetupRegistered(builder.Services);
|
|
|
|
foreach (var interceptor in interceptors)
|
|
{
|
|
builder.Services.AddSingleton(interceptor);
|
|
}
|
|
|
|
return builder;
|
|
}
|
|
|
|
private static void EnsureSetupRegistered(IServiceCollection services)
|
|
{
|
|
// Only register once
|
|
if (services.Any(d => d.ImplementationType == typeof(InterceptorServerOptionsSetup)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
services.AddSingleton<IConfigureOptions<McpServerOptions>, InterceptorServerOptionsSetup>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wraps an interceptor to resolve target instances from DI for instance methods.
|
|
/// </summary>
|
|
private sealed class DeferredInstanceInterceptor<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TTarget> : McpServerInterceptor
|
|
{
|
|
private readonly McpServerInterceptor _inner;
|
|
private readonly MethodInfo _method;
|
|
|
|
internal DeferredInstanceInterceptor(McpServerInterceptor inner, MethodInfo method)
|
|
{
|
|
_inner = inner;
|
|
_method = method;
|
|
}
|
|
|
|
public override Interceptor ProtocolInterceptor => _inner.ProtocolInterceptor;
|
|
public override IReadOnlyList<object> Metadata => _inner.Metadata;
|
|
|
|
public override async ValueTask<InterceptorResult> InvokeAsync(
|
|
InvokeInterceptorRequestParams request,
|
|
McpServer server,
|
|
IServiceProvider? services,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (services is null)
|
|
{
|
|
throw new InvalidOperationException($"Cannot resolve instance of type '{typeof(TTarget).Name}' without a service provider.");
|
|
}
|
|
|
|
var target = (TTarget)services.GetRequiredService(typeof(TTarget));
|
|
|
|
// Create a new interceptor bound to this instance and invoke it
|
|
var boundInterceptor = ReflectionMcpServerInterceptor.Create(_method, target);
|
|
return await boundInterceptor.InvokeAsync(request, server, services, cancellationToken);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures <see cref="McpServerOptions"/> to register interceptor support.
|
|
/// </summary>
|
|
internal sealed class InterceptorServerOptionsSetup : IConfigureOptions<McpServerOptions>
|
|
{
|
|
private readonly IEnumerable<McpServerInterceptor> _interceptors;
|
|
|
|
public InterceptorServerOptionsSetup(IEnumerable<McpServerInterceptor> interceptors)
|
|
{
|
|
_interceptors = interceptors;
|
|
}
|
|
|
|
public void Configure(McpServerOptions options)
|
|
{
|
|
// Collect all interceptors into a primitive collection
|
|
var collection = new McpServerPrimitiveCollection<McpServerInterceptor>();
|
|
var allEvents = new HashSet<string>();
|
|
|
|
foreach (var interceptor in _interceptors)
|
|
{
|
|
collection.Add(interceptor);
|
|
foreach (var hook in interceptor.ProtocolInterceptor.Hooks)
|
|
{
|
|
foreach (var ev in hook.Events)
|
|
{
|
|
allEvents.Add(ev);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register the message filter
|
|
var filter = new InterceptorMessageFilter(collection);
|
|
options.Filters.Message.IncomingFilters.Add(filter.CreateFilter);
|
|
|
|
// Advertise interceptor capability via Extensions
|
|
options.Capabilities ??= new();
|
|
|
|
options.Capabilities.Extensions ??= new Dictionary<string, object>();
|
|
// Serialize to JsonElement so the SDK's own serializer (which doesn't know about
|
|
// InterceptorsCapability) can write it without type-info issues.
|
|
var capability = new InterceptorsCapability
|
|
{
|
|
SupportedEvents = allEvents.ToList(),
|
|
};
|
|
options.Capabilities.Extensions[InterceptorProtocolConstants.ExtensionCapabilityKey] = JsonSerializer.SerializeToElement(
|
|
capability, InterceptorJsonUtilities.DefaultOptions);
|
|
}
|
|
}
|