From 7cf90c9ccb8f3a2c382e2de5c443ab118385818f Mon Sep 17 00:00:00 2001 From: Peder Holdgaard Pedersen <127606677+PederHP@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:56:41 +0200 Subject: [PATCH] C# SDK: align mode value and priorityHint with the SEP (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- csharp/sdk/CLAUDE.md | 4 +- csharp/sdk/README.md | 3 + .../Client/InterceptorChainOrchestrator.cs | 10 +- .../InterceptorJsonContext.cs | 1 + .../Protocol/Interceptor.cs | 8 +- .../Protocol/PriorityHint.cs | 92 +++++++++++++ .../Server/McpServerInterceptorAttribute.cs | 40 +++++- .../Server/ReflectionMcpServerInterceptor.cs | 19 ++- .../InterceptorChainOrchestratorTests.cs | 127 +++++++++++++++++- .../ProtocolTypesSerializationTests.cs | 73 +++++++++- .../ReflectionMcpServerInterceptorTests.cs | 41 +++++- 11 files changed, 407 insertions(+), 11 deletions(-) create mode 100644 csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/PriorityHint.cs diff --git a/csharp/sdk/CLAUDE.md b/csharp/sdk/CLAUDE.md index e7d9da5..e4cf361 100644 --- a/csharp/sdk/CLAUDE.md +++ b/csharp/sdk/CLAUDE.md @@ -6,7 +6,7 @@ C# implementation of gateway-level interceptors from [SEP-2624](https://github.c ## Build & test ``` dotnet build # from csharp/sdk/ -dotnet test # 66 tests across the interceptor test project +dotnet test # 87 tests across the interceptor test project ``` ## Key architectural constraints @@ -22,7 +22,7 @@ dotnet test # 66 tests across the interceptor test project ## Chain execution order (SEP-2624) - **Request phase (sending)**: Mutations (sequential by priority ↑) → Validations (parallel) → Sinks (fire-and-forget) - **Response phase (receiving)**: Validations (parallel) → Sinks (fire-and-forget) → Mutations (sequential by priority ↑) -- Lower `PriorityHint` executes first; ties broken alphabetically by name +- Lower `PriorityHint` executes first; scalar (both phases) or per-phase `{request, response}` form, unset phase resolves to 0; ties broken alphabetically by name ## JSON-RPC methods | Method | Params → Result | diff --git a/csharp/sdk/README.md b/csharp/sdk/README.md index ec336c6..a1d7835 100644 --- a/csharp/sdk/README.md +++ b/csharp/sdk/README.md @@ -49,6 +49,9 @@ public class MyInterceptors } ``` +`PriorityHint` applies to both phases; use `RequestPriorityHint`/`ResponsePriorityHint` instead to +order a mutation differently per phase (unset phases default to 0). + ### Consuming Interceptors from a Client ```csharp diff --git a/csharp/sdk/src/ModelContextProtocol.Interceptors/Client/InterceptorChainOrchestrator.cs b/csharp/sdk/src/ModelContextProtocol.Interceptors/Client/InterceptorChainOrchestrator.cs index 4d1c963..4c32677 100644 --- a/csharp/sdk/src/ModelContextProtocol.Interceptors/Client/InterceptorChainOrchestrator.cs +++ b/csharp/sdk/src/ModelContextProtocol.Interceptors/Client/InterceptorChainOrchestrator.cs @@ -30,6 +30,14 @@ internal static class InterceptorChainOrchestrator ExecuteChainRequestParams chainParams, CancellationToken cancellationToken) { + if (chainParams.Phase is not (InterceptorPhase.Request or InterceptorPhase.Response)) + { + throw new ArgumentException( + $"Chain execution phase must be '{nameof(InterceptorPhase.Request)}' or '{nameof(InterceptorPhase.Response)}'; " + + $"'{chainParams.Phase}' is an attribute-only convenience value that is invalid at execution time.", + nameof(chainParams)); + } + var sw = Stopwatch.StartNew(); var results = new List(); var summary = new ChainValidationSummary(); @@ -40,7 +48,7 @@ internal static class InterceptorChainOrchestrator var applicable = FilterInterceptors(interceptors, chainParams); var mutations = applicable.Where(i => i.Type == InterceptorType.Mutation) - .OrderBy(i => i.PriorityHint ?? 0) + .OrderBy(i => i.PriorityHint?.GetEffective(chainParams.Phase) ?? 0) .ThenBy(i => i.Name, StringComparer.Ordinal) .ToList(); diff --git a/csharp/sdk/src/ModelContextProtocol.Interceptors/InterceptorJsonContext.cs b/csharp/sdk/src/ModelContextProtocol.Interceptors/InterceptorJsonContext.cs index 096c6cd..f271741 100644 --- a/csharp/sdk/src/ModelContextProtocol.Interceptors/InterceptorJsonContext.cs +++ b/csharp/sdk/src/ModelContextProtocol.Interceptors/InterceptorJsonContext.cs @@ -11,6 +11,7 @@ namespace ModelContextProtocol.Interceptors; [JsonSerializable(typeof(InterceptorType))] [JsonSerializable(typeof(InterceptorPhase))] [JsonSerializable(typeof(InterceptorMode))] +[JsonSerializable(typeof(PriorityHint))] [JsonSerializable(typeof(InterceptorCompatibility))] [JsonSerializable(typeof(InterceptorsCapability))] [JsonSerializable(typeof(InterceptorResult))] diff --git a/csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/Interceptor.cs b/csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/Interceptor.cs index e738d53..d3d9534 100644 --- a/csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/Interceptor.cs +++ b/csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/Interceptor.cs @@ -47,9 +47,13 @@ public sealed class Interceptor [JsonPropertyName("failOpen")] public bool? FailOpen { get; set; } - /// Gets or sets the priority hint for ordering mutation interceptors. Lower values execute first. + /// + /// Gets or sets the priority hint for ordering mutation interceptors. Lower values execute first. + /// Either a single number applying to both phases or per-phase request/response + /// values; unset resolves to 0. + /// [JsonPropertyName("priorityHint")] - public int? PriorityHint { get; set; } + public PriorityHint? PriorityHint { get; set; } /// Gets or sets protocol version compatibility constraints. [JsonPropertyName("compat")] diff --git a/csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/PriorityHint.cs b/csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/PriorityHint.cs new file mode 100644 index 0000000..612d1e6 --- /dev/null +++ b/csharp/sdk/src/ModelContextProtocol.Interceptors/Protocol/PriorityHint.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Interceptors.Protocol; + +/// +/// Priority hint for ordering mutation interceptors. Lower values execute first; ties are broken +/// alphabetically by interceptor name. Per the SEP, a hint is either a single number applying to +/// both phases or an object with separate request/response values; an unset phase +/// value resolves to 0. +/// +[JsonConverter(typeof(Converter))] +public sealed class PriorityHint +{ + /// Creates a scalar hint applying the same priority to both phases. + public PriorityHint(int value) + { + Request = value; + Response = value; + IsScalar = true; + } + + /// Creates a per-phase hint. An unset phase resolves to 0. + public PriorityHint(int? request, int? response) + { + Request = request; + Response = response; + } + + /// Gets the request-phase priority, or if unset (resolves to 0). + public int? Request { get; } + + /// Gets the response-phase priority, or if unset (resolves to 0). + public int? Response { get; } + + /// + /// Whether this hint was created or parsed in scalar (single number) form. Controls whether it + /// serializes back as a bare number or an object, preserving round-trip fidelity. + /// + internal bool IsScalar { get; } + + /// Resolves the effective priority for the given phase per the SEP algorithm. + public int GetEffective(InterceptorPhase phase) => + phase == InterceptorPhase.Response ? Response ?? 0 : Request ?? 0; + + /// Converts a plain number to a scalar hint applying to both phases. + public static implicit operator PriorityHint(int value) => new(value); + + internal sealed class Converter : JsonConverter + { + public override PriorityHint? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Number: + return new PriorityHint(reader.GetInt32()); + + case JsonTokenType.StartObject: + int? request = null, response = null; + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var propertyName = reader.GetString(); + reader.Read(); + switch (propertyName) + { + case "request": request = reader.GetInt32(); break; + case "response": response = reader.GetInt32(); break; + default: reader.Skip(); break; + } + } + return new PriorityHint(request, response); + + default: + throw new JsonException($"Unexpected token '{reader.TokenType}' for priorityHint; expected a number or an object."); + } + } + + public override void Write(Utf8JsonWriter writer, PriorityHint value, JsonSerializerOptions options) + { + if (value.IsScalar) + { + writer.WriteNumberValue(value.Request!.Value); + return; + } + + writer.WriteStartObject(); + if (value.Request is int request) writer.WriteNumber("request", request); + if (value.Response is int response) writer.WriteNumber("response", response); + writer.WriteEndObject(); + } + } +} diff --git a/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/McpServerInterceptorAttribute.cs b/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/McpServerInterceptorAttribute.cs index 76693d1..7f6c29b 100644 --- a/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/McpServerInterceptorAttribute.cs +++ b/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/McpServerInterceptorAttribute.cs @@ -28,8 +28,44 @@ public sealed class McpServerInterceptorAttribute : Attribute /// public InterceptorPhase Phase { get; set; } = InterceptorPhase.Both; - /// Gets or sets the priority hint for mutation ordering. Lower values execute first. - public int PriorityHint { get; set; } + private int? _priorityHint; + private int? _requestPriorityHint; + private int? _responsePriorityHint; + + /// + /// Gets or sets the priority hint for mutation ordering, applying to both phases. + /// Lower values execute first. Mutually exclusive with and + /// . Unset is omitted from the wire and resolves to 0. + /// + public int PriorityHint + { + get => _priorityHint ?? 0; + set => _priorityHint = value; + } + + /// + /// Gets or sets the request-phase priority hint for mutation ordering. Lower values execute + /// first. Mutually exclusive with . Unset resolves to 0. + /// + public int RequestPriorityHint + { + get => _requestPriorityHint ?? 0; + set => _requestPriorityHint = value; + } + + /// + /// Gets or sets the response-phase priority hint for mutation ordering. Lower values execute + /// first. Mutually exclusive with . Unset resolves to 0. + /// + public int ResponsePriorityHint + { + get => _responsePriorityHint ?? 0; + set => _responsePriorityHint = value; + } + + internal int? PriorityHintOrNull => _priorityHint; + internal int? RequestPriorityHintOrNull => _requestPriorityHint; + internal int? ResponsePriorityHintOrNull => _responsePriorityHint; /// /// Gets or sets the execution mode. Defaults to . diff --git a/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/ReflectionMcpServerInterceptor.cs b/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/ReflectionMcpServerInterceptor.cs index 9d55ff3..5c00413 100644 --- a/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/ReflectionMcpServerInterceptor.cs +++ b/csharp/sdk/src/ModelContextProtocol.Interceptors/Server/ReflectionMcpServerInterceptor.cs @@ -113,7 +113,7 @@ internal sealed class ReflectionMcpServerInterceptor : McpServerInterceptor Hooks = hooks, Mode = attr.Mode == InterceptorMode.Active ? null : attr.Mode, FailOpen = attr.FailOpen ? true : null, - PriorityHint = attr.PriorityHint, + PriorityHint = ResolvePriorityHint(attr, method.Name), }; // Collect metadata from declaring type and method @@ -130,6 +130,23 @@ internal sealed class ReflectionMcpServerInterceptor : McpServerInterceptor return new ReflectionMcpServerInterceptor(interceptor, metadata, method, target, parameterBinder, resultConverter); } + private static PriorityHint? ResolvePriorityHint(McpServerInterceptorAttribute attr, string methodName) + { + var hasPhaseHint = attr.RequestPriorityHintOrNull is not null || attr.ResponsePriorityHintOrNull is not null; + if (attr.PriorityHintOrNull is int scalar) + { + if (hasPhaseHint) + { + throw new InvalidOperationException( + $"Interceptor method '{methodName}' sets both PriorityHint and RequestPriorityHint/ResponsePriorityHint; use one or the other."); + } + + return scalar; + } + + return hasPhaseHint ? new PriorityHint(attr.RequestPriorityHintOrNull, attr.ResponsePriorityHintOrNull) : null; + } + private static Func BuildParameterBinder(MethodInfo method) { var parameters = method.GetParameters(); diff --git a/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/InterceptorChainOrchestratorTests.cs b/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/InterceptorChainOrchestratorTests.cs index 3210907..a9ab420 100644 --- a/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/InterceptorChainOrchestratorTests.cs +++ b/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/InterceptorChainOrchestratorTests.cs @@ -123,6 +123,110 @@ public class InterceptorChainOrchestratorTests Assert.Equal(["low-priority", "default-priority", "high-priority"], executionOrder); } + [Fact] + public async Task MutationsOrderByPhaseSpecificPriority() + { + // SEP example: PII redactor runs early when sending, late when receiving; + // compressor runs late when sending, early when receiving. + var executionOrder = new List(); + + var pii = CreateInterceptor("pii-redactor", InterceptorType.Mutation, (req, _) => + { + executionOrder.Add("pii-redactor"); + return new ValueTask(new MutationInterceptorResult { Modified = false }); + }, priorityHint: new PriorityHint(request: -50000, response: 50000)); + + var compressor = CreateInterceptor("compressor", InterceptorType.Mutation, (req, _) => + { + executionOrder.Add("compressor"); + return new ValueTask(new MutationInterceptorResult { Modified = false }); + }, priorityHint: new PriorityHint(request: 5000, response: -5000)); + + var requestResult = await RunAsync( + [pii, compressor], + new ExecuteChainRequestParams + { + Event = InterceptionEvents.ToolsCall, + Phase = InterceptorPhase.Request, + Payload = JsonNode.Parse("""{}""")!, + }, + CancellationToken.None); + + Assert.Equal(InterceptorChainStatus.Success, requestResult.Status); + Assert.Equal(["pii-redactor", "compressor"], executionOrder); + + executionOrder.Clear(); + + var responseResult = await RunAsync( + [pii, compressor], + new ExecuteChainRequestParams + { + Event = InterceptionEvents.ToolsCall, + Phase = InterceptorPhase.Response, + Payload = JsonNode.Parse("""{}""")!, + }, + CancellationToken.None); + + Assert.Equal(InterceptorChainStatus.Success, responseResult.Status); + Assert.Equal(["compressor", "pii-redactor"], executionOrder); + } + + [Fact] + public async Task MutationsMixedScalarObjectAndUnsetHintsInterleaveAtDefaultZero() + { + var executionOrder = new List(); + + TestEntry Mut(string name, PriorityHint? hint) => CreateInterceptor(name, InterceptorType.Mutation, (req, _) => + { + executionOrder.Add(name); + return new ValueTask(new MutationInterceptorResult { Modified = false }); + }, priorityHint: hint); + + var scalar = Mut("scalar", -10); + var requestOnly = Mut("request-only", new PriorityHint(request: 5, response: null)); + var responseOnly = Mut("a-response-only", new PriorityHint(request: null, response: -7)); // effective 0 in request phase + var unset = Mut("b-unset", null); // effective 0 + + var result = await RunAsync( + [scalar, requestOnly, responseOnly, unset], + new ExecuteChainRequestParams + { + Event = InterceptionEvents.ToolsCall, + Phase = InterceptorPhase.Request, + Payload = JsonNode.Parse("""{}""")!, + }, + CancellationToken.None); + + Assert.Equal(InterceptorChainStatus.Success, result.Status); + // -10, then the two 0-effective entries tie-broken alphabetically, then 5. + Assert.Equal(["scalar", "a-response-only", "b-unset", "request-only"], executionOrder); + } + + [Fact] + public async Task MutationsTieBreakAlphabeticallyByName() + { + var executionOrder = new List(); + + TestEntry Mut(string name) => CreateInterceptor(name, InterceptorType.Mutation, (req, _) => + { + executionOrder.Add(name); + return new ValueTask(new MutationInterceptorResult { Modified = false }); + }, priorityHint: 10); + + var result = await RunAsync( + [Mut("charlie"), Mut("alpha"), Mut("bravo")], + new ExecuteChainRequestParams + { + Event = InterceptionEvents.ToolsCall, + Phase = InterceptorPhase.Request, + Payload = JsonNode.Parse("""{}""")!, + }, + CancellationToken.None); + + Assert.Equal(InterceptorChainStatus.Success, result.Status); + Assert.Equal(["alpha", "bravo", "charlie"], executionOrder); + } + [Fact] public async Task MutationsChainPayloads() { @@ -407,6 +511,27 @@ public class InterceptorChainOrchestratorTests Assert.Equal("request-only", result.Results[0].InterceptorName); } + [Fact] + public async Task NonWirePhase_Throws() + { + var validation = CreateInterceptor("val", InterceptorType.Validation, (req, _) => + { + return new ValueTask(ValidationInterceptorResult.Success()); + }); + + var ex = await Assert.ThrowsAsync(() => RunAsync( + [validation], + new ExecuteChainRequestParams + { + Event = InterceptionEvents.ToolsCall, + Phase = InterceptorPhase.Both, + Payload = JsonNode.Parse("""{}""")!, + }, + CancellationToken.None).AsTask()); + + Assert.Contains("Both", ex.Message, StringComparison.Ordinal); + } + [Fact] public async Task ValidationSummaryCountsCorrectly() { @@ -459,7 +584,7 @@ public class InterceptorChainOrchestratorTests string name, InterceptorType type, Func> handler, - int priorityHint = 0, + PriorityHint? priorityHint = null, string[]? events = null, InterceptorPhase phase = InterceptorPhase.Both, InterceptorMode? mode = null, diff --git a/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ProtocolTypesSerializationTests.cs b/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ProtocolTypesSerializationTests.cs index db0027a..af04f60 100644 --- a/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ProtocolTypesSerializationTests.cs +++ b/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ProtocolTypesSerializationTests.cs @@ -70,6 +70,8 @@ public class ProtocolTypesSerializationTests }; var json = JsonSerializer.Serialize(interceptor, Options); + Assert.Contains("\"priorityHint\":-1000", json); + var deserialized = JsonSerializer.Deserialize(json, Options)!; Assert.Equal("pii-validator", deserialized.Name); @@ -80,11 +82,73 @@ public class ProtocolTypesSerializationTests Assert.Equal(InterceptorPhase.Request, deserialized.Hooks[0].Phase); Assert.Equal(InterceptorPhase.Response, deserialized.Hooks[1].Phase); Assert.Equal(InterceptorType.Validation, deserialized.Type); - Assert.Equal(-1000, deserialized.PriorityHint); + Assert.Equal(-1000, deserialized.PriorityHint!.GetEffective(InterceptorPhase.Request)); + Assert.Equal(-1000, deserialized.PriorityHint.GetEffective(InterceptorPhase.Response)); Assert.NotNull(deserialized.Compat); Assert.Equal("2024-11-05", deserialized.Compat.MinProtocol); } + [Fact] + public void PriorityHint_ScalarJson_RoundTripsAsNumber() + { + var hint = JsonSerializer.Deserialize("-5", Options)!; + + Assert.Equal(-5, hint.Request); + Assert.Equal(-5, hint.Response); + Assert.Equal("-5", JsonSerializer.Serialize(hint, Options)); + } + + [Fact] + public void PriorityHint_ObjectForm_RoundTripsAsObject() + { + var hint = new PriorityHint(-50000, 50000); + + var json = JsonSerializer.Serialize(hint, Options); + Assert.Equal("""{"request":-50000,"response":50000}""", json); + + var deserialized = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal(-50000, deserialized.Request); + Assert.Equal(50000, deserialized.Response); + Assert.Equal(json, JsonSerializer.Serialize(deserialized, Options)); + } + + [Fact] + public void PriorityHint_PartialObject_ResolvesUnsetPhaseToZero() + { + var hint = JsonSerializer.Deserialize("""{"response":1000}""", Options)!; + + Assert.Null(hint.Request); + Assert.Equal(0, hint.GetEffective(InterceptorPhase.Request)); + Assert.Equal(1000, hint.GetEffective(InterceptorPhase.Response)); + Assert.Equal("""{"response":1000}""", JsonSerializer.Serialize(hint, Options)); + } + + [Fact] + public void PriorityHint_EqualValuedObject_StaysObjectForm() + { + var hint = JsonSerializer.Deserialize("""{"request":5,"response":5}""", Options)!; + + Assert.Equal("""{"request":5,"response":5}""", JsonSerializer.Serialize(hint, Options)); + } + + [Fact] + public void PriorityHint_UnknownKeysAndEmptyObject_AreTolerated() + { + var withUnknown = JsonSerializer.Deserialize("""{"request":7,"future":{"a":1}}""", Options)!; + Assert.Equal(7, withUnknown.Request); + Assert.Null(withUnknown.Response); + + var empty = JsonSerializer.Deserialize("{}", Options)!; + Assert.Equal(0, empty.GetEffective(InterceptorPhase.Request)); + Assert.Equal(0, empty.GetEffective(InterceptorPhase.Response)); + } + + [Fact] + public void PriorityHint_InvalidToken_Throws() + { + Assert.Throws(() => JsonSerializer.Deserialize("\"high\"", Options)); + } + [Fact] public void Interceptor_OmitsNullFields() { @@ -115,6 +179,13 @@ public class ProtocolTypesSerializationTests Assert.Equal("\"audit\"", JsonSerializer.Serialize(InterceptorMode.Audit, Options)); } + [Fact] + public void InterceptorMode_DeserializesFromString() + { + Assert.Equal(InterceptorMode.Active, JsonSerializer.Deserialize("\"active\"", Options)); + Assert.Equal(InterceptorMode.Audit, JsonSerializer.Deserialize("\"audit\"", Options)); + } + [Fact] public void Interceptor_RoundTripsModeAndFailOpen() { diff --git a/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ReflectionMcpServerInterceptorTests.cs b/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ReflectionMcpServerInterceptorTests.cs index abfc19d..0941a2c 100644 --- a/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ReflectionMcpServerInterceptorTests.cs +++ b/csharp/sdk/tests/ModelContextProtocol.Interceptors.Tests/ReflectionMcpServerInterceptorTests.cs @@ -29,6 +29,14 @@ public class TestInterceptors 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) { @@ -127,7 +135,8 @@ public class ReflectionMcpServerInterceptorTests var interceptor = ReflectionMcpServerInterceptor.Create(method, target: null); Assert.Equal("mutator", interceptor.ProtocolInterceptor.Name); - Assert.Equal(-100, interceptor.ProtocolInterceptor.PriorityHint); + Assert.Equal(-100, interceptor.ProtocolInterceptor.PriorityHint!.GetEffective(InterceptorPhase.Request)); + Assert.Equal(-100, interceptor.ProtocolInterceptor.PriorityHint.GetEffective(InterceptorPhase.Response)); var request = new InvokeInterceptorRequestParams { @@ -183,6 +192,36 @@ public class ReflectionMcpServerInterceptorTests 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(() => ReflectionMcpServerInterceptor.Create(method, target: null)); + } + [Fact] public void Create_MethodWithoutAttribute_Throws() {