diff --git a/csharp/sdk/CLAUDE.md b/csharp/sdk/CLAUDE.md index 80104ab..4955562 100644 --- a/csharp/sdk/CLAUDE.md +++ b/csharp/sdk/CLAUDE.md @@ -6,7 +6,7 @@ C# implementation of gateway-level interceptors from [SEP-1763](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-1763) - **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 56f9b6b..3d59abc 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 async ValueTask ExecuteAsync( 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 async ValueTask ExecuteAsync( 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 static McpServerInterceptor Create(MethodInfo method, object? target, M 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 static McpServerInterceptor Create(MethodInfo method, object? target, M 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 async Task MutationsExecuteSequentiallyByPriority() 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 async Task FiltersInterceptorsByPhase() 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 @@ private static TestEntry CreateInterceptor( 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 void Interceptor_RoundTrips() }; 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 void Interceptor_RoundTrips() 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 void InterceptorMode_SerializesAsString() 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 static MutationInterceptorResult Mutate(JsonNode payload) 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 async Task Invoke_MutationReturnsModifiedPayload() 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 async Task Invoke_AsyncMethod_ReturnsCorrectResult() 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() {