Skip to content

Commit bfa239f

Browse files
committed
Implement Go interceptor chain
Add the interceptor implementation for servers built with the go-sdk. Interceptors sit between the transport and method handlers, enabling policy enforcement, data sanitization, and traffic auditing without modifying handler code. The core interceptor framework (chain engine, validators, mutators, invocations, results) is fully protocol-agnostic with zero MCP dependencies, so it can be used with any server that processes request/response pairs (gRPC, custom HTTP, etc.). The MCP-specific server integration lives in a separate mcpserver sub-package, wiring interceptors into go-sdk's middleware for all transports (stdio, HTTP). This covers the server-side chain execution model only. The SEP's protocol-level methods (interceptors/register, interceptors/update, interceptors/list, interceptors/execute) are not yet implemented. Package structure: interceptors/ chain.go — public Chain type, protocol-agnostic API chain_executor.go — chainExecutor, filtering, sorting, snapshots chain_validate.go — parallel validator dispatch chain_mutate.go — sequential mutator execution interceptor.go — types, Metadata, Validator/Mutator structs invocation.go — Invocation with audit-mode payload cloning result.go — ValidationResult, MutationResult, ChainResult doc.go — package documentation with examples interceptors/mcpserver/ server.go — Server wrapper, middleware, capability declaration events.go — MCP event name constants doc.go — sub-package documentation Also includes: - examples/validator and examples/mutator - HTTP integration tests (httptest + StreamableHTTPHandler) - doc/DESIGN.md, doc/CONFORMANCE.md, doc/PERFORMANCE.md Signed-off-by: Kurt Degiorgio <kdegiorgio@bloomberg.net>
1 parent 45f9e0e commit bfa239f

27 files changed

Lines changed: 3070 additions & 19 deletions

.github/workflows/go.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
runs-on: ubuntu-latest
5252
strategy:
5353
matrix:
54-
go: ["1.23", "1.24", "1.25"]
54+
go: ["1.24", "1.25"]
5555
steps:
5656
- name: Check out code
5757
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*.dll
55
*.so
66
*.dylib
7+
mutator
8+
validator
79

810
# Build results
911
[Bb]in/

go/sdk/README.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,57 @@
11
# MCP Interceptors - Go Implementation
22

3-
This will contain the Go implementation of the MCP Interceptors based on [SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763).
3+
Go implementation of the MCP Interceptor Extension based on
4+
[SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763).
5+
6+
## Quick Start
7+
8+
```go
9+
mcpServer := mcp.NewServer(&mcp.Implementation{
10+
Name: "my-server",
11+
Version: "0.1.0",
12+
}, nil)
13+
14+
// Wrap with interceptor support.
15+
srv := interceptors.NewServer(mcpServer,
16+
// Optional Context Provider
17+
interceptors.WithContextProvider(
18+
func(_ context.Context, _ mcp.Request) *interceptors.InvocationContext {
19+
return &interceptors.InvocationContext{
20+
Principal: &interceptors.Principal{Type: "user", ID: "alice"},
21+
}
22+
},
23+
),
24+
)
25+
26+
// Register a validator that blocks dangerous tool calls.
27+
srv.AddInterceptor(&interceptors.Validator{
28+
Metadata: interceptors.Metadata{
29+
Name: "block-dangerous",
30+
Events: []string{interceptors.EventToolsCall},
31+
Phase: interceptors.PhaseRequest,
32+
Mode: interceptors.ModeOn,
33+
},
34+
Handler: func(_ context.Context, inv *interceptors.Invocation) (*interceptors.ValidationResult, error) {
35+
// validate the request...
36+
return &interceptors.ValidationResult{Valid: true}, nil
37+
},
38+
})
39+
40+
srv.Run(context.Background(), &mcp.StdioTransport{})
41+
```
42+
43+
See [`examples/`](examples/) for complete working examples.
44+
45+
## Documentation
46+
47+
- [**DESIGN.md**](doc/DESIGN.md) — architecture, execution model, integration
48+
with the go-sdk.
49+
- [**PERFORMANCE.md**](doc/PERFORMANCE.md) — per-request cost model, allocation
50+
summary, and optimization notes.
51+
- [**CONFORMANCE.md**](doc/CONFORMANCE.md) — SEP conformance status.
52+
53+
Package API documentation is available via `go doc`:
54+
55+
```sh
56+
go doc github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors
57+
```

go/sdk/doc/CONFORMANCE.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# SEP Conformance
2+
3+
Status of this Go SDK implementation against the
4+
[SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763)
5+
interceptor proposal.
6+
7+
## Implemented
8+
9+
| Area | Notes |
10+
|------|-------|
11+
| Validation interceptors | Parallel execution, severity-based blocking, fail-open support |
12+
| Mutation interceptors | Sequential execution, priority ordering, atomic payload updates |
13+
| Interceptor metadata | Name, version, description, events, phase, priorityHint (polymorphic JSON), compat, configSchema, mode, failOpen, timeout |
14+
| Event names | Constants for all standard server-side MCP methods; JSON-RPC method names used directly as event names |
15+
| Unified result envelope | ValidationResult, MutationResult, ExecutionResult with base envelope fields |
16+
| Chain result | ChainResult with status, results, finalPayload, validationSummary, abortedAt |
17+
| JSON-RPC error mapping | Typed error data structs: -32602 for validation, -32603 for mutation, -32000 for timeout |
18+
| Trust-boundary execution order | Receiving: validate (parallel) then mutate (sequential); Sending: mutate (sequential) then validate (parallel) |
19+
| Priority ordering | Mutators sorted by `priorityHint.Resolve(phase)` ascending, alphabetical tiebreak |
20+
| Fail-open behavior | `FailOpen: true` interceptors log errors without aborting the chain |
21+
| Audit mode | `ModeAudit` records results without blocking or applying mutations |
22+
| Timeout & context | Per-interceptor timeouts, chain-level context cancellation, `InvocationContext` with principal/traceId via `mcpserver.WithContextProvider` |
23+
| Receiving direction (client → server) | All server-side method calls intercepted via `AddReceivingMiddleware` |
24+
| Capability declaration | Interceptor metadata injected into `initialize` response via `Capabilities.Experimental` |
25+
| First-party (in-process) deployment | Interceptors run as Go functions within the server process |
26+
| Third-party and hybrid deployment | Handlers can call remote services; local and remote interceptors can be mixed freely. No built-in remote interceptor abstraction yet |
27+
28+
## Not Implemented
29+
30+
| Area | SEP expects | Notes |
31+
|------|-------------|-------|
32+
| Wildcard event matching | `type InterceptorEvent = ... \| "*/request" \| "*/response" \| "*"` | `matchesEvent` does exact match only; wildcard patterns are planned |
33+
| Protocol methods | `interceptors/list`, `interceptor/invoke`, `interceptor/executeChain` | Requires upstream go-sdk changes to register custom JSON-RPC methods |
34+
| Server → client interception | Client features as interceptable events: `"sampling/createMessage"`, `"elicitation/create"`, `"roots/list"` | Requires a `sendingMiddleware` installed via `Server.AddSendingMiddleware`. Outgoing requests run mutate → validate, incoming responses run validate → mutate (same `executeForSending`/`executeForReceiving` methods). Interceptors match by event name (no API changes needed for registration) |

go/sdk/doc/DESIGN.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Go SDK Interceptors — Design Document
2+
3+
## Integration Point: Receiving Middleware
4+
5+
The go-sdk processes an incoming JSON-RPC message in this order:
6+
7+
```
8+
Transport (SSE / stdio)
9+
→ JSON-RPC decode
10+
→ Params deserialization (json.RawMessage → typed struct)
11+
→ Receiving middleware chain ← we hook in here
12+
→ Method handler (e.g. tool handler)
13+
→ Result returned through middleware
14+
→ JSON-RPC encode
15+
→ Transport
16+
```
17+
18+
## Capability Declaration
19+
20+
During initialization, the middleware intercepts the `"initialize"` response
21+
and injects interceptor metadata into
22+
`Capabilities.Experimental["io.modelcontextprotocol/interceptors"]`. This
23+
follows the same pattern as the variants extension
24+
(`io.modelcontextprotocol/server-variants`).
25+
26+
The capability payload includes:
27+
- `supportedEvents` — deduplicated list of events with registered interceptors
28+
- `interceptors` — full metadata array in wire format
29+
30+
## Request/Response Lifecycle
31+
32+
When a JSON-RPC request arrives, `receivingMiddleware` in
33+
`mcpserver/server.go` runs the following sequence:
34+
35+
```
36+
0. If method == "initialize" → enrich result with capability declaration
37+
1. Assign typed params to Invocation inv.Payload = req.GetParams()
38+
2. Run request-phase chain (validate → mutate)
39+
3. If aborted → return error
40+
4. Params already modified in place — no unmarshal needed
41+
5. Call next handler next(ctx, method, req)
42+
6. Assign result to Invocation inv.Payload = result
43+
7. Run response-phase chain (mutate → validate)
44+
8. If aborted → return error
45+
9. Result already modified in place — no unmarshal needed
46+
10. Return result
47+
```
48+
49+
The JSON-RPC method name is used directly as the event name (e.g. `"tools/call"`).
50+
51+
### Typed Payload — Zero JSON Operations
52+
53+
`Invocation.Payload` is `any`, the live Go value from the go-sdk
54+
(e.g. `*mcp.CallToolParamsRaw`). Handlers type-assert directly, the
55+
same pattern as gRPC-Go interceptors (`req any`). No JSON marshaling
56+
or unmarshaling occurs in the normal path.
57+
58+
```go
59+
// Validator — type-assert, inspect, return:
60+
params, ok := inv.Payload.(*mcp.CallToolParamsRaw)
61+
if !ok {
62+
return nil, fmt.Errorf("unexpected payload type %T", inv.Payload)
63+
}
64+
65+
// Mutator — type-assert, modify in place, return:
66+
result, ok := inv.Payload.(*mcp.CallToolResult)
67+
if !ok {
68+
return nil, fmt.Errorf("unexpected payload type %T", inv.Payload)
69+
}
70+
result.Content[0] = &mcp.TextContent{Text: "modified"}
71+
return &MutationResult{Modified: true}, nil
72+
```
73+
74+
**Audit mode:** Audit-mode mutators receive a deep-copied payload (via
75+
`Invocation.withCopiedPayload()`) so their in-place modifications don't
76+
affect the real struct. The deep copy uses a JSON round-trip
77+
(`json.Marshal``reflect.New``json.Unmarshal`). Only audit-mode
78+
mutators pay this cost.
79+
80+
### Limitations
81+
82+
- **Params must round-trip through JSON faithfully** for audit-mode deep
83+
copy. All go-sdk param and result types use standard `encoding/json`
84+
tags, so this holds in practice.
85+
- **Type assertions require knowing the concrete type.** Interceptors must
86+
know which type to expect for a given event (e.g. `*mcp.CallToolParamsRaw`
87+
for `tools/call` requests). The `Events` field on `Metadata` narrows
88+
which events reach a handler, so single-event interceptors always see
89+
the expected type.
90+
91+
---
92+
93+
## What Is and Is Not Intercepted
94+
95+
### Intercepted
96+
97+
All JSON-RPC **method calls** routed through the server's receiving middleware:
98+
99+
| Method | Event |
100+
|--------|-------|
101+
| `tools/call` | `EventToolsCall` |
102+
| `tools/list` | `EventToolsList` |
103+
| `prompts/get` | `EventPromptsGet` |
104+
| `prompts/list` | `EventPromptsList` |
105+
| `resources/read` | `EventResourcesRead` |
106+
| `resources/list` | `EventResourcesList` |
107+
| `resources/subscribe` | `EventResourcesSubscribe` |
108+
109+
Unknown methods pass through the middleware untouched.
110+
111+
### Not Intercepted
112+
113+
1. **Progress notifications.** During a tool call, a handler can call
114+
`session.NotifyProgress()`. These are JSON-RPC *notifications* sent
115+
directly over the transport — they do not flow through `MethodHandler`
116+
middleware. Interceptors never see them.
117+
118+
2. **Transport-level SSE streaming.** The Streamable HTTP transport
119+
multiplexes multiple JSON-RPC messages over a single SSE connection.
120+
This is connection management, not per-message streaming. Each individual
121+
method call is still a single request → single response, which the
122+
middleware intercepts normally.
123+
124+
3. **JSON-RPC notifications** (e.g. `notifications/initialized`,
125+
`notifications/cancelled`). The go-sdk routes notifications through a
126+
separate handler path, not through `MethodHandler` middleware.
127+
128+
Notification interception is not defined by the proposal.
129+
If this becomes necessary, it would require a separate notification middleware
130+
hook in the go-sdk.
131+
132+
---
133+
134+
## Chain Execution Model
135+
136+
The `chainExecutor` in `chain_executor.go` implements trust-boundary-aware
137+
execution:
138+
139+
**Request phase** (receiving data — untrusted → trusted):
140+
```
141+
Validate (parallel) → Mutate (sequential)
142+
```
143+
Validation acts as a security gate before mutations process the data.
144+
145+
**Response phase** (sending data — trusted → untrusted):
146+
```
147+
Mutate (sequential) → Validate (parallel)
148+
```
149+
Mutations prepare/sanitize data, then validation verifies before sending.
150+
151+
### Validator execution
152+
- All matching validators run in parallel (goroutines).
153+
- A validator returning `Valid: false` with `Severity: "error"` in enforced
154+
mode (`Mode: ModeOn`) aborts the chain.
155+
- `FailOpen: true` validators log errors and record an `ExecutionResult`
156+
(with `Error` populated) for observability, but don't abort.
157+
158+
### Mutator execution
159+
- Mutators run sequentially, ordered by `PriorityHint.Resolve(phase)`
160+
(ascending), with alphabetical name tiebreak.
161+
- Each mutator modifies the typed payload in place via type assertion on `inv.Payload`.
162+
- If any mutator fails (and is not `FailOpen`), the chain aborts.
163+
`FailOpen` mutators record an `ExecutionResult` (with `Error` populated)
164+
and continue.
165+
- In `ModeAudit`, the mutator runs on a deep-copied payload and its result
166+
is recorded, but the real payload is not affected.
167+
168+
### Filtering
169+
`newChainExecutor` filters the full interceptor set by:
170+
1. `Mode != ModeOff`
171+
2. Phase matches (or interceptor phase is `PhaseBoth`)
172+
3. Event matches (exact match only; wildcard support is planned via `matchesEvent`)
173+
174+
---
175+
176+
## File Map
177+
178+
### `interceptors/` — protocol-agnostic core (zero MCP imports)
179+
180+
| File | Responsibility |
181+
|------|---------------|
182+
| `interceptor.go` | Types (Phase, Mode, InterceptorType, Priority, Severity, Compat), Metadata struct, Interceptor interface, Validator/Mutator structs and handler types |
183+
| `invocation.go` | Invocation (with audit-mode payload cloning), InvocationContext, Principal — the input to every handler |
184+
| `result.go` | All outcome types: ValidationResult, MutationResult, ExecutionResult, ChainResult, AbortInfo |
185+
| `chain.go` | `Chain` public API: `NewChain`, `Add`, `ExecuteForReceiving`, `ExecuteForSending`, `IsEmpty`, `Interceptors` |
186+
| `chain_executor.go` | `interceptorSnapshot` (atomic snapshot with lazy chain cache), `chainExecutor` struct, `newChainExecutor` (filtering + sorting), `executeForReceiving`, `executeForSending`, `timeoutResult`, `matchesPhase`, `matchesEvent` |
187+
| `chain_validate.go` | `validatorResult` struct, `runValidators` (parallel dispatch + N=1 fast path), `executeValidator`, `recordValidation` |
188+
| `chain_mutate.go` | `mutatorOutcome` type + constants, `runMutators` (sequential loop + audit-mode copy), `executeMutator` |
189+
190+
### `interceptors/mcpserver/` — MCP server integration
191+
192+
| File | Responsibility |
193+
|------|---------------|
194+
| `server.go` | `Server` wrapper, `receivingMiddleware`, `WithContextProvider`, capability declaration, `NewStreamableHTTPHandler`, `abortToJSONRPCError` |
195+
| `events.go` | Event name constants for standard MCP methods |

go/sdk/doc/PERFORMANCE.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Go SDK Interceptors — Performance
2+
3+
Analysis of per-request costs and allocation patterns.
4+
5+
---
6+
7+
## Design Rationale: Typed Payload
8+
9+
The interceptor chain passes Go's typed params/result objects directly
10+
to handlers, avoiding JSON serialization entirely in the normal path.
11+
This follows the same pattern used by gRPC-Go, where interceptors
12+
receive the request as `any` and type-assert to the concrete type.
13+
14+
`Invocation.Payload` is `any, the same pointer the go-sdk already
15+
allocated during JSON-RPC deserialization. No wrapper struct, no
16+
intermediate copies. Mutators modify the value in place through the
17+
pointer (no marshal/unmarshal round-trip)
18+
19+
---
20+
21+
## Per-Request Cost Model
22+
23+
Every intercepted request passes through the middleware in
24+
`mcpserver/server.go`. The cost depends on whether interceptors match
25+
the event.
26+
27+
### Fast Path (no matching interceptors)
28+
29+
When no interceptors match, the middleware cost is:
30+
31+
1. One atomic pointer load: `s.chain.snapshot.Load()`
32+
2. Two `sync.Map` lookups on the chain cache: `getChain(event, PhaseRequest)` and `getChain(event, PhaseResponse)`
33+
3. One boolean check: `ce.empty`
34+
35+
Zero allocations, zero JSON operations.
36+
37+
### Intercepted Path
38+
39+
With interceptors active, each active phase
40+
incurs:
41+
42+
| Step | Operation | Allocations | JSON ops |
43+
|------|-----------|-------------|----------|
44+
| 1 | `Invocation` struct | 1 struct | 0 |
45+
| 2 | `ChainResult` struct (with pre-allocated `Results` slice) | 1 struct + 1 slice | 0 |
46+
| 3 | Validator execution | 0 (N=1) / goroutines (N>1) | 0 |
47+
| 4 | Mutator execution | 0 | 0 |
48+
| 5 | Audit-mode deep copy | 1 per audit mutator | 1 marshal + 1 unmarshal |
49+
50+
Zero JSON operations in the normal path. Phases with no matching
51+
interceptors are skipped entirely.

go/sdk/examples/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)