Addresses the first two items of #15 (the chain-orchestrator multi-server item follows in a separate PR). ## `mode`: keep `"active"`, add `"audit"` round-trip coverage > **Update:** an earlier revision of this PR renamed `mode` from `"active"` to `"enforce"`, following a SEP **draft**. The now-current revised SEP-1763 ([PR #2624](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2624)) defines `mode?: "active" | "audit"` with default `"active"` — so `"active"` was correct all along. @jeongukjae flagged this; the rename has been reverted (commit 58edf2c). The C# SDK's wire value `"active"` matches the SEP and the TypeScript SDK. `InterceptorMode` keeps members `Active` (wire `"active"`, default) and `Audit` (wire `"audit"`). Added a deserialization test alongside the existing serialization test so both wire forms are covered. ## `priorityHint`: support `number | { request?, response? }` The SDK only supported a plain `int`. This adds a `PriorityHint` protocol type whose converter accepts both wire forms and preserves them on round-trip (a number stays a number, an object stays an object). Chain ordering now resolves the effective priority per phase exactly as the SEP's `resolvePriority` algorithm (unset → 0, alphabetical tie-break unchanged). - `Interceptor.PriorityHint` is now `PriorityHint?`; an implicit conversion from `int` keeps existing code compiling. - `[McpServerInterceptor]` gains `RequestPriorityHint` / `ResponsePriorityHint` (mutually exclusive with the scalar `PriorityHint`), following the official SDK's backing-field pattern for optional attribute values. - Behavior note: attribute-discovered interceptors with no priority set now omit `priorityHint` from the wire instead of emitting `0` — semantically identical per the SEP default, and consistent with the existing default-skipping for `Mode`/`FailOpen`. ## Testing `dotnet test` from `csharp/sdk/`: all passing, including new coverage for both `priorityHint` wire forms, partial objects, unknown-key tolerance, phase-dependent mutation ordering (the SEP's pii-redactor/compressor example), the attribute conflict error, and both `mode` wire values. Part of #15. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
231 lines
9.7 KiB
C#
231 lines
9.7 KiB
C#
using System.Text.Json.Nodes;
|
|
using ModelContextProtocol.Interceptors.Protocol;
|
|
using ModelContextProtocol.Interceptors.Server;
|
|
using Xunit;
|
|
|
|
namespace ModelContextProtocol.Interceptors.Tests;
|
|
|
|
[McpServerInterceptorType]
|
|
public class TestInterceptors
|
|
{
|
|
[McpServerInterceptor(Name = "bool-validator", Type = InterceptorType.Validation, Events = ["tools/call"])]
|
|
public static bool ValidateWithBool(JsonNode payload) => payload["valid"]?.GetValue<bool>() ?? false;
|
|
|
|
[McpServerInterceptor(Name = "result-validator", Type = InterceptorType.Validation, Events = ["tools/call"])]
|
|
public static ValidationInterceptorResult ValidateWithResult(JsonNode payload, string @event, InterceptorPhase phase)
|
|
{
|
|
return new ValidationInterceptorResult
|
|
{
|
|
Valid = true,
|
|
Messages = [new ValidationMessage { Message = $"Validated {payload} for {@event} in {phase}", Severity = ValidationSeverity.Info }],
|
|
};
|
|
}
|
|
|
|
[McpServerInterceptor(Name = "mutator", Type = InterceptorType.Mutation, Events = ["tools/call"], PriorityHint = -100)]
|
|
public static MutationInterceptorResult Mutate(JsonNode payload)
|
|
{
|
|
var obj = payload.AsObject();
|
|
obj["mutated"] = true;
|
|
return new MutationInterceptorResult { Modified = true, Payload = obj };
|
|
}
|
|
|
|
[McpServerInterceptor(Name = "phase-mutator", Type = InterceptorType.Mutation, Events = ["tools/call"], RequestPriorityHint = -50000, ResponsePriorityHint = 50000)]
|
|
public static MutationInterceptorResult MutateWithPhasePriorities(JsonNode payload)
|
|
=> new() { Modified = false };
|
|
|
|
[McpServerInterceptor(Name = "conflicting-mutator", Type = InterceptorType.Mutation, Events = ["tools/call"], PriorityHint = -100, RequestPriorityHint = -50000)]
|
|
public static MutationInterceptorResult MutateWithConflictingPriorities(JsonNode payload)
|
|
=> new() { Modified = false };
|
|
|
|
[McpServerInterceptor(Name = "sink", Type = InterceptorType.Sink, Events = ["*"])]
|
|
public static SinkInterceptorResult Sink(JsonNode payload, InvokeInterceptorContext? context)
|
|
{
|
|
return new SinkInterceptorResult
|
|
{
|
|
Recorded = true,
|
|
Metrics = new Dictionary<string, double> { ["payloadSize"] = payload.ToJsonString().Length },
|
|
};
|
|
}
|
|
|
|
[McpServerInterceptor(Name = "async-validator", Type = InterceptorType.Validation, Events = ["tools/call"])]
|
|
public static async Task<ValidationInterceptorResult> ValidateAsync(JsonNode payload, CancellationToken ct)
|
|
{
|
|
await Task.Delay(1, ct);
|
|
return ValidationInterceptorResult.Success();
|
|
}
|
|
}
|
|
|
|
public class ReflectionMcpServerInterceptorTests
|
|
{
|
|
[Fact]
|
|
public void Create_FromMethodWithAttribute_ExtractsMetadata()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithBool))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
Assert.Equal("bool-validator", interceptor.ProtocolInterceptor.Name);
|
|
Assert.Equal(InterceptorType.Validation, interceptor.ProtocolInterceptor.Type);
|
|
Assert.Contains(interceptor.ProtocolInterceptor.Hooks, h => h.Events.Contains("tools/call"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invoke_BoolReturn_WrapsAsValidationResult()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithBool))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
var request = new InvokeInterceptorRequestParams
|
|
{
|
|
Name = "bool-validator",
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = JsonNode.Parse("""{"valid":true}""")!,
|
|
};
|
|
|
|
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
|
|
var validation = Assert.IsType<ValidationInterceptorResult>(result);
|
|
Assert.True(validation.Valid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invoke_BoolReturn_FalseWrapsAsInvalid()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithBool))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
var request = new InvokeInterceptorRequestParams
|
|
{
|
|
Name = "bool-validator",
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = JsonNode.Parse("""{"valid":false}""")!,
|
|
};
|
|
|
|
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
|
|
var validation = Assert.IsType<ValidationInterceptorResult>(result);
|
|
Assert.False(validation.Valid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invoke_BindsEventAndPhase()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithResult))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
var request = new InvokeInterceptorRequestParams
|
|
{
|
|
Name = "result-validator",
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Response,
|
|
Payload = JsonNode.Parse("""{"test":true}""")!,
|
|
};
|
|
|
|
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
|
|
var validation = Assert.IsType<ValidationInterceptorResult>(result);
|
|
Assert.True(validation.Valid);
|
|
Assert.Single(validation.Messages!);
|
|
Assert.Contains("tools/call", validation.Messages![0].Message);
|
|
Assert.Contains("Response", validation.Messages[0].Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invoke_MutationReturnsModifiedPayload()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.Mutate))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
Assert.Equal("mutator", interceptor.ProtocolInterceptor.Name);
|
|
Assert.Equal(-100, interceptor.ProtocolInterceptor.PriorityHint!.GetEffective(InterceptorPhase.Request));
|
|
Assert.Equal(-100, interceptor.ProtocolInterceptor.PriorityHint.GetEffective(InterceptorPhase.Response));
|
|
|
|
var request = new InvokeInterceptorRequestParams
|
|
{
|
|
Name = "mutator",
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = JsonNode.Parse("""{"original":true}""")!,
|
|
};
|
|
|
|
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
|
|
var mutation = Assert.IsType<MutationInterceptorResult>(result);
|
|
Assert.True(mutation.Modified);
|
|
Assert.True(mutation.Payload!["mutated"]!.GetValue<bool>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invoke_SinkBindsContext()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.Sink))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
var request = new InvokeInterceptorRequestParams
|
|
{
|
|
Name = "sink",
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = JsonNode.Parse("""{"data":"test"}""")!,
|
|
Context = new InvokeInterceptorContext { TraceId = "trace-123" },
|
|
};
|
|
|
|
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
|
|
var sink = Assert.IsType<SinkInterceptorResult>(result);
|
|
Assert.True(sink.Recorded);
|
|
Assert.True(sink.Metrics!["payloadSize"] > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invoke_AsyncMethod_ReturnsCorrectResult()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateAsync))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
var request = new InvokeInterceptorRequestParams
|
|
{
|
|
Name = "async-validator",
|
|
Event = InterceptionEvents.ToolsCall,
|
|
Phase = InterceptorPhase.Request,
|
|
Payload = JsonNode.Parse("""{}""")!,
|
|
};
|
|
|
|
var result = await interceptor.InvokeAsync(request, null!, null, CancellationToken.None);
|
|
var validation = Assert.IsType<ValidationInterceptorResult>(result);
|
|
Assert.True(validation.Valid);
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_PerPhasePriorityHints_ProduceObjectFormHint()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.MutateWithPhasePriorities))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
var hint = interceptor.ProtocolInterceptor.PriorityHint;
|
|
Assert.NotNull(hint);
|
|
Assert.Equal(-50000, hint!.Request);
|
|
Assert.Equal(50000, hint.Response);
|
|
Assert.Equal(-50000, hint.GetEffective(InterceptorPhase.Request));
|
|
Assert.Equal(50000, hint.GetEffective(InterceptorPhase.Response));
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_NoPriorityHint_OmitsHint()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.ValidateWithBool))!;
|
|
var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null);
|
|
|
|
Assert.Null(interceptor.ProtocolInterceptor.PriorityHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_ScalarAndPerPhasePriorityHints_Throws()
|
|
{
|
|
var method = typeof(TestInterceptors).GetMethod(nameof(TestInterceptors.MutateWithConflictingPriorities))!;
|
|
Assert.Throws<InvalidOperationException>(() => ReflectionMcpServerInterceptor.Create(method, target: null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_MethodWithoutAttribute_Throws()
|
|
{
|
|
var method = typeof(string).GetMethod(nameof(string.ToString), Type.EmptyTypes)!;
|
|
Assert.Throws<InvalidOperationException>(() => ReflectionMcpServerInterceptor.Create(method, target: null));
|
|
}
|
|
}
|