Introduce InterceptorOverrides in the chain execution model (#24)
Introduce InterceptorOverrides in the chain execution model to separate interceptor capability (server-declared) from execution policy (invoker-declared). ## Why The SEP currently locks all execution policy (failOpen, priorityHint, mode) into the interceptor's own definition. This means: - If an interceptor declares `failOpen: false` but is known to be flaky in your deployment, you can't make it fail-open without changing the server - If you want the same interceptor in audit mode in staging but active in production, there's no mechanism - If you want to restrict an interceptor to only `tools/call` even though it declares support for `tools/call` + `llm/completion`, you have to invoke it and hope the chain filters correctly - Priority is fixed by the author, so platform teams can't reorder interceptors for their deployment topology In short: the chain interface had no policy layer, making it impractical for real-world multi-environment deployments. ## Changes 1. New "Capability vs Policy" subsection explaining the two-layer model 2. New `InterceptorOverrides` interface (failOpen, priorityHint, mode, timeoutMs, hooks) 3. ChainEntry gains `overrides?: InterceptorOverrides` 4. `resolvePriority()` updated to check overrides first 5. Orchestration steps updated — new step 2 "Resolve Policy" 6. New example showing overrides in action (priority override, mode promotion, fail-open for fragile interceptor) 7. New rationale entry (Design Decision 4) explaining why both layers exist Signed-off-by: Ukjae Jeong <jeongukjae@gmail.com> Co-authored-by: Kurt Degiorgio <Degiorgio@users.noreply.github.com>
This commit is contained in:
parent
99bc7c9bb0
commit
eebd2ac6a2
1 changed files with 161 additions and 13 deletions
174
docs/sep.md
174
docs/sep.md
|
|
@ -826,17 +826,17 @@ Key semantics:
|
||||||
|
|
||||||
#### Priority Resolution
|
#### Priority Resolution
|
||||||
|
|
||||||
When ordering mutations, the invoker resolves phase-specific priorities:
|
When ordering mutations, the invoker resolves phase-specific priorities. If the chain entry has `overrides.priorityHint`, it takes precedence over the interceptor's declared default:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function resolvePriority(
|
function resolvePriority(
|
||||||
interceptor: Interceptor,
|
entry: ChainEntry,
|
||||||
phase: "request" | "response",
|
phase: "request" | "response",
|
||||||
): number {
|
): number {
|
||||||
if (interceptor.priorityHint === undefined) return 0;
|
const hint = entry.overrides?.priorityHint ?? entry.interceptor.priorityHint;
|
||||||
if (typeof interceptor.priorityHint === "number")
|
if (hint === undefined) return 0;
|
||||||
return interceptor.priorityHint;
|
if (typeof hint === "number") return hint;
|
||||||
return interceptor.priorityHint[phase] ?? 0;
|
return hint[phase] ?? 0;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1050,13 +1050,31 @@ Chain execution is a **convenience utility**, provided by SDKs to enforce the ex
|
||||||
|
|
||||||
> Important: The execution model defined above MUST be followed by all implementations, whether invoking interceptors individually via `interceptor/invoke` or using this convenience utility.
|
> Important: The execution model defined above MUST be followed by all implementations, whether invoking interceptors individually via `interceptor/invoke` or using this convenience utility.
|
||||||
|
|
||||||
|
##### Capability vs Policy
|
||||||
|
|
||||||
|
The chain distinguishes between two layers of interceptor configuration:
|
||||||
|
|
||||||
|
- **Capability** (server-declared): The interceptor's type, hooks, and `configSchema` as advertised via `interceptors/list`. These describe what the interceptor *can* do.
|
||||||
|
- **Policy** (invoker-declared): Execution parameters such as `failOpen`, `priorityHint`, `mode`, `timeoutMs`, and hook narrowing that the invoker provides via `overrides` on each chain entry. These describe how the interceptor *should* behave in a specific deployment.
|
||||||
|
|
||||||
|
Interceptor servers declare **defaults** for policy fields (`failOpen`, `priorityHint`, `mode`) alongside their capabilities. Invokers MAY provide **overrides** per-interceptor in the chain to adjust execution policy. When an override is present, it takes precedence over the server-declared default. Hook overrides can only **narrow** the set of events (restrict to a subset of declared hooks), never **widen** (add events the interceptor does not declare). Implementations MUST reject override hooks that reference events not present in the interceptor's declared hooks.
|
||||||
|
|
||||||
|
This separation enables:
|
||||||
|
|
||||||
|
- Same interceptor running in different modes across deployments (audit in staging, active in production)
|
||||||
|
- Platform teams adjusting `failOpen` and `timeoutMs` based on SLO requirements without modifying interceptor servers
|
||||||
|
- Fine-grained routing via hook narrowing without server-side changes
|
||||||
|
|
||||||
|
##### Orchestration
|
||||||
|
|
||||||
The orchestration pattern is as follows:
|
The orchestration pattern is as follows:
|
||||||
|
|
||||||
1. **Discover:** Call `interceptors/list` on one or more MCP servers to collect all registered interceptors.
|
1. **Discover:** Call `interceptors/list` on one or more MCP servers to collect all registered interceptors.
|
||||||
2. **Merge & Sort:** Combine all discovered interceptors into a single chain, sorted by `priorityHint` (ascending, with alphabetical tie-breaking by interceptor name).
|
2. **Resolve Policy:** For each chain entry, merge `overrides` with interceptor defaults (overrides take precedence). Filter entries whose override `hooks` exclude the current event/phase.
|
||||||
3. **Order by Trust Boundary:** Apply the trust-boundary-aware execution model — mutations before validations when sending, validations before mutations when receiving.
|
3. **Sort:** Order mutations by resolved `priorityHint` (ascending, with alphabetical tie-breaking by interceptor name).
|
||||||
4. **Execute:** Call `interceptor/invoke` on the appropriate MCP server for each interceptor. Mutations MUST be invoked sequentially (each receiving the output of the previous). Validations MAY be invoked in parallel.
|
4. **Order by Trust Boundary:** Apply the trust-boundary-aware execution model — mutations before validations when sending, validations before mutations when receiving.
|
||||||
5. **Aggregate:** Collect all results, assembling the final payload and validation summary.
|
5. **Execute:** Call `interceptor/invoke` on the appropriate MCP server for each interceptor, applying resolved `failOpen`, `mode`, and `timeoutMs`. Mutations MUST be invoked sequentially (each receiving the output of the previous). Validations MAY be invoked in parallel.
|
||||||
|
6. **Aggregate:** Collect all results, assembling the final payload and validation summary.
|
||||||
|
|
||||||
SDK libraries are expected to provide reference implementations of this orchestration logic so that individual applications do not need to implement it from scratch.
|
SDK libraries are expected to provide reference implementations of this orchestration logic so that individual applications do not need to implement it from scratch.
|
||||||
|
|
||||||
|
|
@ -1081,7 +1099,8 @@ interface InterceptorChain {
|
||||||
|
|
||||||
interface ChainEntry {
|
interface ChainEntry {
|
||||||
/**
|
/**
|
||||||
* The interceptor descriptor (as returned by interceptors/list)
|
* The interceptor descriptor (as returned by interceptors/list).
|
||||||
|
* Contains the interceptor's capabilities and author-declared defaults.
|
||||||
*/
|
*/
|
||||||
interceptor: {
|
interceptor: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -1091,7 +1110,7 @@ interface ChainEntry {
|
||||||
phase: "request" | "response";
|
phase: "request" | "response";
|
||||||
}>;
|
}>;
|
||||||
priorityHint?: number | { request?: number; response?: number };
|
priorityHint?: number | { request?: number; response?: number };
|
||||||
mode?: "audit";
|
mode?: "active" | "audit";
|
||||||
failOpen?: boolean;
|
failOpen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1101,6 +1120,54 @@ interface ChainEntry {
|
||||||
* The concrete type is implementation-specific.
|
* The concrete type is implementation-specific.
|
||||||
*/
|
*/
|
||||||
server: MCPServerConnection;
|
server: MCPServerConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional invoker-side overrides for this interceptor's execution policy.
|
||||||
|
* Fields present here take precedence over the interceptor's declared defaults.
|
||||||
|
*/
|
||||||
|
overrides?: InterceptorOverrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InterceptorOverrides {
|
||||||
|
/**
|
||||||
|
* Override failure routing policy for this interceptor.
|
||||||
|
* Takes precedence over the interceptor's declared failOpen default.
|
||||||
|
*/
|
||||||
|
failOpen?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override mutation ordering priority.
|
||||||
|
* Takes precedence over the interceptor's declared priorityHint default.
|
||||||
|
* Ignored for validation interceptors.
|
||||||
|
*/
|
||||||
|
priorityHint?: number | { request?: number; response?: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override execution mode.
|
||||||
|
* Takes precedence over the interceptor's declared mode default.
|
||||||
|
* - "active": Normal blocking / transforming behavior
|
||||||
|
* - "audit": Non-blocking operation (log-only for validators, shadow for mutators)
|
||||||
|
*/
|
||||||
|
mode?: "active" | "audit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-interceptor timeout in milliseconds.
|
||||||
|
* If exceeded, the interceptor is cancelled and the resolved failOpen
|
||||||
|
* determines whether the message proceeds or is blocked.
|
||||||
|
*/
|
||||||
|
timeoutMs?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow the interceptor's declared hooks.
|
||||||
|
* Each entry MUST be a subset of the interceptor's declared hooks.
|
||||||
|
* If provided, the interceptor is only invoked for matching events/phases
|
||||||
|
* from this narrowed set rather than its full declared hooks.
|
||||||
|
* Implementations MUST reject overrides that widen beyond declared hooks.
|
||||||
|
*/
|
||||||
|
hooks?: Array<{
|
||||||
|
events: InterceptionEvent[];
|
||||||
|
phase: "request" | "response";
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1278,6 +1345,76 @@ For a `tools/call` event, assume the following interceptor are available:
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### Example: Chain Entries with Overrides
|
||||||
|
|
||||||
|
The following example shows how invoker-side overrides adjust execution policy without modifying interceptor servers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Chain entries with invoker overrides applied:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
interceptor: {
|
||||||
|
name: "pii-redactor",
|
||||||
|
type: "mutation",
|
||||||
|
hooks: [
|
||||||
|
{ events: ["tools/call", "llm/completion"], phase: "request" },
|
||||||
|
{ events: ["tools/call", "llm/completion"], phase: "response" },
|
||||||
|
],
|
||||||
|
priorityHint: { request: -1000, response: 1000 },
|
||||||
|
failOpen: false,
|
||||||
|
},
|
||||||
|
server: serverA,
|
||||||
|
overrides: {
|
||||||
|
// Override priority for this deployment
|
||||||
|
priorityHint: { request: -2000, response: 2000 },
|
||||||
|
// Set a per-interceptor timeout
|
||||||
|
timeoutMs: 3000,
|
||||||
|
// Narrow hooks: only intercept tools/call, not llm/completion
|
||||||
|
hooks: [
|
||||||
|
{ events: ["tools/call"], phase: "request" },
|
||||||
|
{ events: ["tools/call"], phase: "response" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interceptor: {
|
||||||
|
name: "audit-logger",
|
||||||
|
type: "validation",
|
||||||
|
hooks: [
|
||||||
|
{ events: ["*"], phase: "request" },
|
||||||
|
{ events: ["*"], phase: "response" },
|
||||||
|
],
|
||||||
|
mode: "audit",
|
||||||
|
failOpen: true,
|
||||||
|
},
|
||||||
|
server: serverB,
|
||||||
|
overrides: {
|
||||||
|
// Promote from audit to active in production
|
||||||
|
mode: "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interceptor: {
|
||||||
|
name: "schema-validator",
|
||||||
|
type: "validation",
|
||||||
|
hooks: [{ events: ["tools/call"], phase: "request" }],
|
||||||
|
failOpen: false,
|
||||||
|
},
|
||||||
|
server: serverC,
|
||||||
|
overrides: {
|
||||||
|
// Known fragile interceptor: fail-open in this deployment
|
||||||
|
failOpen: true,
|
||||||
|
timeoutMs: 2000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example:
|
||||||
|
- `pii-redactor` runs with higher priority and tighter timeout than the author declared, and only for `tools/call` (not `llm/completion`)
|
||||||
|
- `audit-logger` is promoted from audit mode to active, making its violations blocking
|
||||||
|
- `schema-validator` is configured fail-open with a short timeout because the invoker knows it can be unreliable
|
||||||
|
|
||||||
##### Full Request/Response Cycle
|
##### Full Request/Response Cycle
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -1549,7 +1686,18 @@ Mutations declare `priorityHint` (default: 0) which can differ by phase. Benefit
|
||||||
|
|
||||||
Alternative (global fixed ordering) rejected as too rigid for phase-aware operations like compression/decompression.
|
Alternative (global fixed ordering) rejected as too rigid for phase-aware operations like compression/decompression.
|
||||||
|
|
||||||
**4. Other Design Choices**
|
**4. Separation of Capability and Policy**
|
||||||
|
|
||||||
|
The chain separates interceptor **capability** (what the server advertises) from execution **policy** (what the invoker configures via `overrides`):
|
||||||
|
|
||||||
|
- **Deployment flexibility**: The same interceptor can run in audit mode in staging and active mode in production without server changes
|
||||||
|
- **SLO adaptation**: Platform teams adjust `failOpen` and `timeoutMs` per-deployment based on reliability requirements
|
||||||
|
- **Routing control**: Hook narrowing lets invokers restrict which events reach an interceptor without server reconfiguration
|
||||||
|
- **Author defaults respected**: Server-declared values remain meaningful as recommended configuration; overrides are opt-in
|
||||||
|
|
||||||
|
Alternative (policy only at server side) rejected because enterprise deployments need invoker-side control over execution behavior without coupling to interceptor authors' choices. Alternative (no server defaults) rejected because it forces every invoker to fully configure every interceptor.
|
||||||
|
|
||||||
|
**5. Other Design Choices**
|
||||||
|
|
||||||
- **Hook-Based Targeting**: Interceptors declare specific hooks (vs. all traffic) for performance and security
|
- **Hook-Based Targeting**: Interceptors declare specific hooks (vs. all traffic) for performance and security
|
||||||
- **Severity Levels** (info/warn/error): Graduated response vs. binary pass/fail enables audit logging without blocking
|
- **Severity Levels** (info/warn/error): Graduated response vs. binary pass/fail enables audit logging without blocking
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue