Skip to content

Commit e9bc172

Browse files
committed
Implement Go interceptor chain
Add the interceptor implementation for MCP servers built with the go-sdk. Interceptors sit between the JSON-RPC transport and method handlers, enabling policy enforcement, data sanitization, and traffic auditing without modifying handler code. 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: interceptor.go — types, Metadata, Validator/Mutator structs invocation.go — Invocation with audit-mode payload cloning result.go — ValidationResult, MutationResult, ChainResult events.go — event name constants chain.go — chainExecutor, filtering, sorting, execution chain_validate.go — parallel validator dispatch chain_mutate.go — sequential mutator execution server.go — Server wrapper, middleware, capability declaration doc.go — package documentation with examples 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 e9bc172

23 files changed

Lines changed: 2266 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 `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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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 `server.go` runs
33+
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.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 but don't abort.
156+
157+
### Mutator execution
158+
- Mutators run sequentially, ordered by `PriorityHint.Resolve(phase)`
159+
(ascending), with alphabetical name tiebreak.
160+
- Each mutator modifies the typed payload in place via type assertion on `inv.Payload`.
161+
- If any mutator fails (and is not `FailOpen`), the chain aborts.
162+
- In `ModeAudit`, the mutator runs on a deep-copied payload and its result
163+
is recorded, but the real payload is not affected.
164+
165+
### Filtering
166+
`newChainExecutor` filters the full interceptor set by:
167+
1. `Mode != ModeOff`
168+
2. Phase matches (or interceptor phase is `PhaseBoth`)
169+
3. Event matches (exact match only; wildcard support is planned via `matchesEvent`)
170+
171+
---
172+
173+
## File Map
174+
175+
| File | Responsibility |
176+
|------|---------------|
177+
| `interceptor.go` | Types (Phase, Mode, InterceptorType, Priority, Severity, Compat), Metadata struct, Interceptor interface, Validator/Mutator structs and handler types |
178+
| `invocation.go` | Invocation (with audit-mode payload cloning), InvocationContext, Principal — the input to every handler |
179+
| `result.go` | All outcome types: ValidationResult, MutationResult, ExecutionResult, ChainResult, AbortInfo; error data structs and `abortToJSONRPCError` |
180+
| `events.go` | Event name constants |
181+
| `chain.go` | `chainExecutor` struct, `newChainExecutor` (filtering + sorting), `executeForReceiving`, `executeForSending`, `timeoutResult`, `matchesPhase`, `matchesEvent` |
182+
| `chain_validate.go` | `validatorResult` struct, `runValidators` (parallel dispatch + N=1 fast path), `executeValidator`, `recordValidation` |
183+
| `chain_mutate.go` | `mutatorOutcome` type + constants, `runMutators` (sequential loop + audit-mode copy), `executeMutator` |
184+
| `server.go` | `Server` wrapper, `interceptorSnapshot` (atomic snapshot with lazy chain cache), `receivingMiddleware`, `WithContextProvider`, capability declaration, `NewStreamableHTTPHandler` |

go/sdk/doc/PERFORMANCE.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 `server.go`.
24+
The cost depends on whether interceptors match the event.
25+
26+
### Fast Path (no matching interceptors)
27+
28+
When no interceptors match, the middleware cost is:
29+
30+
1. One atomic pointer load: `s.snapshot.Load()`
31+
2. Two `sync.Map` lookups: `getChain(event, phase)`
32+
3. One boolean check: `chain.empty`
33+
34+
Zero allocations, zero JSON operations.
35+
36+
### Intercepted Path
37+
38+
With interceptors active, each active phase
39+
incurs:
40+
41+
| Step | Operation | Allocations | JSON ops |
42+
|------|-----------|-------------|----------|
43+
| 1 | `Invocation` struct | 1 struct | 0 |
44+
| 2 | `ChainResult` struct (with pre-allocated `Results` slice) | 1 struct + 1 slice | 0 |
45+
| 3 | Validator execution | 0 (N=1) / goroutines (N>1) | 0 |
46+
| 4 | Mutator execution | 0 | 0 |
47+
| 5 | Audit-mode deep copy | 1 per audit mutator | 1 marshal + 1 unmarshal |
48+
49+
Zero JSON operations in the normal path. Phases with no matching
50+
interceptors are skipped entirely.

go/sdk/examples/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)