Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions csharp/sdk/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions csharp/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ internal static async ValueTask<InterceptorChainResult> 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<InterceptorResult>();
var summary = new ChainValidationSummary();
Expand All @@ -40,7 +48,7 @@ internal static async ValueTask<InterceptorChainResult> 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();
Comment thread
PederHP marked this conversation as resolved.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<InvokeInterceptorRequestParams, McpServer, IServiceProvider?, CancellationToken, object?[]> BuildParameterBinder(MethodInfo method)
{
var parameters = method.GetParameters();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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()
{
Expand Down Expand Up @@ -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<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()
{
Expand Down Expand Up @@ -459,7 +584,7 @@ private static TestEntry CreateInterceptor(
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,
Expand Down
Loading
Loading