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:
Peder Holdgaard Pedersen 2026-07-21 15:56:41 +02:00 • committed by GitHub
commit 7cf90c9ccb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 407 additions and 11 deletions

View file

@ -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,

View file

@ -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()
{

View file

@ -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()
{