C# SDK: align mode value and priorityHint with the SEP (#17)
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>
This commit is contained in:
parent
eebd2ac6a2
commit
7cf90c9ccb
11 changed files with 407 additions and 11 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<InterceptorResult>();
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -47,9 +47,13 @@ public sealed class Interceptor
|
|||
[JsonPropertyName("failOpen")]
|
||||
public bool? FailOpen { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the priority hint for ordering mutation interceptors. Lower values execute first.</summary>
|
||||
/// <summary>
|
||||
/// 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 <c>request</c>/<c>response</c>
|
||||
/// values; unset resolves to 0.
|
||||
/// </summary>
|
||||
[JsonPropertyName("priorityHint")]
|
||||
public int? PriorityHint { get; set; }
|
||||
public PriorityHint? PriorityHint { get; set; }
|
||||
|
||||
/// <summary>Gets or sets protocol version compatibility constraints.</summary>
|
||||
[JsonPropertyName("compat")]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Interceptors.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>request</c>/<c>response</c> values; an unset phase
|
||||
/// value resolves to 0.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(Converter))]
|
||||
public sealed class PriorityHint
|
||||
{
|
||||
/// <summary>Creates a scalar hint applying the same priority to both phases.</summary>
|
||||
public PriorityHint(int value)
|
||||
{
|
||||
Request = value;
|
||||
Response = value;
|
||||
IsScalar = true;
|
||||
}
|
||||
|
||||
/// <summary>Creates a per-phase hint. An unset phase resolves to 0.</summary>
|
||||
public PriorityHint(int? request, int? response)
|
||||
{
|
||||
Request = request;
|
||||
Response = response;
|
||||
}
|
||||
|
||||
/// <summary>Gets the request-phase priority, or <see langword="null"/> if unset (resolves to 0).</summary>
|
||||
public int? Request { get; }
|
||||
|
||||
/// <summary>Gets the response-phase priority, or <see langword="null"/> if unset (resolves to 0).</summary>
|
||||
public int? Response { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal bool IsScalar { get; }
|
||||
|
||||
/// <summary>Resolves the effective priority for the given phase per the SEP algorithm.</summary>
|
||||
public int GetEffective(InterceptorPhase phase) =>
|
||||
phase == InterceptorPhase.Response ? Response ?? 0 : Request ?? 0;
|
||||
|
||||
/// <summary>Converts a plain number to a scalar hint applying to both phases.</summary>
|
||||
public static implicit operator PriorityHint(int value) => new(value);
|
||||
|
||||
internal sealed class Converter : JsonConverter<PriorityHint>
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,8 +28,44 @@ public sealed class McpServerInterceptorAttribute : Attribute
|
|||
/// </summary>
|
||||
public InterceptorPhase Phase { get; set; } = InterceptorPhase.Both;
|
||||
|
||||
/// <summary>Gets or sets the priority hint for mutation ordering. Lower values execute first.</summary>
|
||||
public int PriorityHint { get; set; }
|
||||
private int? _priorityHint;
|
||||
private int? _requestPriorityHint;
|
||||
private int? _responsePriorityHint;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the priority hint for mutation ordering, applying to both phases.
|
||||
/// Lower values execute first. Mutually exclusive with <see cref="RequestPriorityHint"/> and
|
||||
/// <see cref="ResponsePriorityHint"/>. Unset is omitted from the wire and resolves to 0.
|
||||
/// </summary>
|
||||
public int PriorityHint
|
||||
{
|
||||
get => _priorityHint ?? 0;
|
||||
set => _priorityHint = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the request-phase priority hint for mutation ordering. Lower values execute
|
||||
/// first. Mutually exclusive with <see cref="PriorityHint"/>. Unset resolves to 0.
|
||||
/// </summary>
|
||||
public int RequestPriorityHint
|
||||
{
|
||||
get => _requestPriorityHint ?? 0;
|
||||
set => _requestPriorityHint = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response-phase priority hint for mutation ordering. Lower values execute
|
||||
/// first. Mutually exclusive with <see cref="PriorityHint"/>. Unset resolves to 0.
|
||||
/// </summary>
|
||||
public int ResponsePriorityHint
|
||||
{
|
||||
get => _responsePriorityHint ?? 0;
|
||||
set => _responsePriorityHint = value;
|
||||
}
|
||||
|
||||
internal int? PriorityHintOrNull => _priorityHint;
|
||||
internal int? RequestPriorityHintOrNull => _requestPriorityHint;
|
||||
internal int? ResponsePriorityHintOrNull => _responsePriorityHint;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the execution mode. Defaults to <see cref="InterceptorMode.Active"/>.
|
||||
|
|
|
|||
|
|
@ -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<InvokeInterceptorRequestParams, McpServer, IServiceProvider?, CancellationToken, object?[]> BuildParameterBinder(MethodInfo method)
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
var pii = CreateInterceptor("pii-redactor", InterceptorType.Mutation, (req, _) =>
|
||||
{
|
||||
executionOrder.Add("pii-redactor");
|
||||
return new ValueTask<InterceptorResult>(new MutationInterceptorResult { Modified = false });
|
||||
}, priorityHint: new PriorityHint(request: -50000, response: 50000));
|
||||
|
||||
var compressor = CreateInterceptor("compressor", InterceptorType.Mutation, (req, _) =>
|
||||
{
|
||||
executionOrder.Add("compressor");
|
||||
return new ValueTask<InterceptorResult>(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<string>();
|
||||
|
||||
TestEntry Mut(string name, PriorityHint? hint) => CreateInterceptor(name, InterceptorType.Mutation, (req, _) =>
|
||||
{
|
||||
executionOrder.Add(name);
|
||||
return new ValueTask<InterceptorResult>(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<string>();
|
||||
|
||||
TestEntry Mut(string name) => CreateInterceptor(name, InterceptorType.Mutation, (req, _) =>
|
||||
{
|
||||
executionOrder.Add(name);
|
||||
return new ValueTask<InterceptorResult>(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<InterceptorResult>(ValidationInterceptorResult.Success());
|
||||
});
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() => 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<InvokeInterceptorRequestParams, CancellationToken, ValueTask<InterceptorResult>> handler,
|
||||
int priorityHint = 0,
|
||||
PriorityHint? priorityHint = null,
|
||||
string[]? events = null,
|
||||
InterceptorPhase phase = InterceptorPhase.Both,
|
||||
InterceptorMode? mode = null,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ public class ProtocolTypesSerializationTests
|
|||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(interceptor, Options);
|
||||
Assert.Contains("\"priorityHint\":-1000", json);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<Interceptor>(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<PriorityHint>("-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<PriorityHint>(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<PriorityHint>("""{"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<PriorityHint>("""{"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<PriorityHint>("""{"request":7,"future":{"a":1}}""", Options)!;
|
||||
Assert.Equal(7, withUnknown.Request);
|
||||
Assert.Null(withUnknown.Response);
|
||||
|
||||
var empty = JsonSerializer.Deserialize<PriorityHint>("{}", Options)!;
|
||||
Assert.Equal(0, empty.GetEffective(InterceptorPhase.Request));
|
||||
Assert.Equal(0, empty.GetEffective(InterceptorPhase.Response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityHint_InvalidToken_Throws()
|
||||
{
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PriorityHint>("\"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<InterceptorMode>("\"active\"", Options));
|
||||
Assert.Equal(InterceptorMode.Audit, JsonSerializer.Deserialize<InterceptorMode>("\"audit\"", Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interceptor_RoundTripsModeAndFailOpen()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() => ReflectionMcpServerInterceptor.Create(method, target: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_MethodWithoutAttribute_Throws()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue