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
1 changed files with 407 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue