Implement Go interceptor chain (#4)

## Summary

Implements the Go SDK for MCP interceptors — validation and mutation
middleware for any MCP server.

The framework treats interceptors as first-class MCP primitives: they
are registered on an MCP server, discoverable via `interceptors/list`,
and invocable via `interceptor/invoke` — the same JSON-RPC pattern as
tools, prompts, and resources. A chain engine orchestrates execution
across one or more interceptor servers, and an optional go-sdk
middleware integration applies interceptors automatically on every
matching request/response.

## Architecture

**Three-layer design:**

1. **`interceptors`** — Protocol-agnostic core. Defines interceptor
types (validators, mutators), metadata, invocation, wire types, and
results. Zero MCP dependencies.

2. **`interceptors/mcpserver`** — MCP server integration. Wraps
`mcp.Server` to register `interceptors/list` and `interceptor/invoke` as
custom JSON-RPC methods and advertise interceptor capabilities during
`initialize`.

3. **`interceptors/chain`** — Chain engine. Orchestrates execution
across N interceptor servers with trust-boundary-aware ordering,
priority-based mutation sequencing, parallel validation, audit-mode
isolation, and fail-open/fail-closed semantics.

4. **`interceptors/integrations/gomiddleware`** — Optional go-sdk
middleware. Installs on `mcp.Server` via `AddReceivingMiddleware()` to
automatically run the interceptor chain on every matching
request/response.

**Key design decisions:**

- **Trust-boundary ordering** — request phase: validate → mutate;
response phase: mutate → validate. Validation always guards the boundary
crossing.
- **Wire-level invocation** — the chain calls `interceptor/invoke` over
the MCP transport (even in-process via `LocalChain()`), so local and
remote interceptor servers use the same interface.
- **Audit-mode isolation** — audit-mode mutators compute transformations
without applying them. Only audit mutators pay the serialization cost.
- **Concurrency** — validators run in parallel; mutators run
sequentially in priority order.

### Package Structure

| Package | File | Responsibility |
|---------|------|----------------|
| `interceptors` | `interceptor.go` | Enums, Metadata, Interceptor
interface, Validator/Mutator structs, handler types, Invocation |
| `interceptors` | `wire.go` | Wire types: ListParams/Result,
InvokeParams/Result, InterceptorInfo, event/method constants |
| `interceptors` | `priority.go` | Priority with phase-aware resolution
and JSON marshaling |
| `interceptors` | `doc.go` | Package documentation with examples |
| `interceptors/chain` | `chain.go` | Chain engine: discovery,
filtering, sorting, execution orchestration |
| `interceptors/chain` | `result.go` | ExecutionResult, AbortInfo |
| `interceptors/chain` | `doc.go` | Sub-package documentation |
| `interceptors/mcpserver` | `server.go` | MCP server wrapper,
interceptor registry, capability advertisement, `LocalChain()` |
| `interceptors/mcpserver` | `rpc.go` | `interceptors/list` and
`interceptor/invoke` JSON-RPC handlers |
| `interceptors/mcpserver` | `doc.go` | Sub-package documentation |
| `integrations/gomiddleware` | `middleware.go` | go-sdk `Middleware`
that runs the chain on every request/response |
| `integrations/gomiddleware` | `errors.go` | Abort-to-JSONRPC error
conversion |
| `integrations/gomiddleware` | `doc.go` | Sub-package documentation |

### Documentation

- `doc/DESIGN.md` — architecture, execution model, integration with
go-sdk
- `doc/CONFORMANCE.md` — SEP conformance status
- `doc/PERFORMANCE.md` — per-request cost model

### Examples

- `examples/validator` — blocks dangerous tool calls, demonstrates
`WithContextProvider`
- `examples/mutator` — tags tool responses with audit trail

## Vendor: go-sdk v1.4.0 with custom method support

Vendors `go-sdk` with minimal extensions for custom JSON-RPC methods:

- `Server.AddCustomMethod` — registers a handler that bypasses the typed
pipeline
- `ClientSession.CallCustom` — sends raw JSON-RPC calls for custom
methods

These changes are generic (not interceptor-specific) and enable any MCP
extension to define its own JSON-RPC methods.

## TODO

- More integration tests
- Unit tests
- Support wildcards for event interception (separate PR)

---------

Signed-off-by: Kurt Degiorgio <kdegiorgio@bloomberg.net>
This commit is contained in:
Kurt Degiorgio 2026-04-29 15:29:25 +01:00 • committed by GitHub
commit c9900f02ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
273 changed files with 62908 additions and 19 deletions

19
.githooks/pre-commit Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
# Skip Go checks if no Go files are staged
if ! git diff --cached --name-only | grep -q '\.go$'; then
echo "==> No Go files staged, skipping Go checks"
exit 0
fi
cd go/sdk
echo "==> gofmt"
gofmt -w .
echo "==> go vet"
go vet ./...
echo "==> staticcheck"
staticcheck ./...

View file

@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
go: ["1.23", "1.24", "1.25"] go: ["1.24", "1.25"]
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

3
.gitignore vendored
View file

@ -4,6 +4,9 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
# Go example binaries (compiled output only, not source directories)
/go/sdk/examples/mutator/mutator
/go/sdk/examples/validator/validator
# Build results # Build results
[Bb]in/ [Bb]in/

1731
docs/sep.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,59 @@
# MCP Interceptors - Go Implementation # MCP Interceptors - Go Implementation
This will contain the Go implementation of the MCP Interceptors based on [SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763). Go implementation of the MCP Interceptor Extension based on
[SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763).
Note: Currently the MCP SDK is vendored, in-order to add the Protocol Methods needed for interceptors.
## Quick Start
```go
mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "my-server",
Version: "0.1.0",
}, nil)
// Wrap with interceptor support.
srv := mcpserver.NewServer(mcpServer)
// Register a validator that blocks dangerous tool calls.
srv.AddInterceptor(&interceptors.Validator{
Metadata: interceptors.Metadata{
Name: "block-dangerous",
Hook: interceptors.Hook{
Events: []string{interceptors.EventToolsCall},
Phase: interceptors.PhaseRequest,
},
Mode: interceptors.ModeEnforce,
},
Handler: func(_ context.Context, inv *interceptors.Invocation) (*interceptors.ValidationResult, error) {
raw := inv.Payload.(json.RawMessage)
var params struct{ Name string `json:"name"` }
json.Unmarshal(raw, &params)
// validate the request...
return &interceptors.ValidationResult{Valid: true}, nil
},
})
// Create a chain and install middleware for automatic execution.
chain, err := srv.LocalChain(ctx)
mcpServer.AddReceivingMiddleware(gomiddleware.Middleware(chain))
srv.Run(context.Background(), &mcp.StdioTransport{})
```
See [`examples/`](examples/) for complete working examples.
## Documentation
- [**DESIGN.md**](doc/DESIGN.md) — architecture, execution model, integration
with the go-sdk.
- [**PERFORMANCE.md**](doc/PERFORMANCE.md) — per-request cost model, allocation
summary, and optimization notes.
- [**CONFORMANCE.md**](doc/CONFORMANCE.md) — SEP conformance status.
Package API documentation is available via `go doc`:
```sh
go doc github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors
```

39
go/sdk/doc/CONFORMANCE.md Normal file
View file

@ -0,0 +1,39 @@
# SEP Conformance
Status of this Go SDK implementation against the SEP-1763 interceptor proposal.
## Implemented
| Area | Notes |
|------|-------|
| Validation interceptors | Parallel execution, severity-based blocking, fail-open support |
| Mutation interceptors | Sequential execution, priority ordering, payload threading via `json.RawMessage` |
| Interceptor metadata | Name, version, description, events, phase, priorityHint (polymorphic JSON), compat, configSchema, mode, failOpen |
| Event names | Constants for all standard server-side MCP methods; JSON-RPC method names used directly as event names |
| Protocol methods | `interceptors/list` for discovery, `interceptor/invoke` for invocation — both registered as custom JSON-RPC methods |
| `InterceptorChain` (SEP) | `chain.Chain` type with `chain.ChainEntry` objects pairing interceptor descriptors with MCP client sessions |
| `ChainEntry` (SEP) | `chain.ChainEntry` struct holds `InterceptorInfo` + `*mcp.ClientSession` |
| Chain discovery | `chain.Chain.AddMCPServer()` calls `interceptors/list` to discover interceptors from an MCP server |
| Chain execution | `chain.Chain.Execute()` invokes interceptors via `interceptor/invoke` on the appropriate server per entry |
| `InvokeResult` envelope | Per-interceptor result with interceptor name, type, phase, duration, validation/mutation result, mutated payload |
| `chain.ExecutionResult` | Aggregated chain result with status, results, finalPayload, validationSummary, abortedAt, totalDurationMs |
| JSON-RPC error mapping | Typed error data structs: -32602 for validation, -32603 for mutation, -32000 for timeout |
| Trust-boundary execution order | Request: validate (parallel) then mutate (sequential); Response: mutate (sequential) then validate (parallel) |
| Priority ordering | Mutators sorted by `priorityHint.Resolve(phase)` ascending, alphabetical tiebreak |
| Fail-open behavior | `FailOpen: true` interceptors log errors without aborting the chain |
| Audit mode | `ModeAudit` records results without blocking; mutated payloads not propagated |
| Timeout & context | Per-interceptor timeouts via `InvokeParams.TimeoutMs`, chain-level context cancellation, `InvocationContext` with principal/traceId |
| Receiving direction (client → server) | All server-side method calls intercepted via `AddReceivingMiddleware` |
| Capability declaration | Interceptor metadata injected into `initialize` response via `Capabilities.Experimental` |
| First-party (in-process) deployment | `Server.LocalChain()` creates an in-memory transport `chain.Chain`; interceptors invoked via JSON-RPC even in-process |
| Third-party and hybrid deployment | Chain entries can point to any `*mcp.ClientSession` — local (in-memory), stdio, or HTTP transport |
| `json.RawMessage` payloads | Interceptor handlers receive `json.RawMessage` via `interceptor/invoke`, matching the SEP's JSON-level payload model |
## Not Implemented
| Area | SEP expects | Notes |
|------|-------------|-------|
| Wildcard event matching | `"*/request"`, `"*/response"`, `"*"` | `matchesEvent` does exact match only; wildcard patterns are planned |
| Server → client interception | Client features as interceptable events: `"sampling/createMessage"`, `"elicitation/create"`, `"roots/list"` | Requires `Server.AddSendingMiddleware` support in the go-sdk |
| Per-interceptor config passthrough | `ChainExecutionParams.Config` map | Wired through to `InvokeParams.Config` but not yet used by middleware |
| Remote interceptor servers | Connecting chain to external MCP servers over stdio/HTTP | Infrastructure is ready (`chain.Chain.AddMCPServer` accepts any `*mcp.ClientSession`); no convenience helpers yet |

221
go/sdk/doc/DESIGN.md Normal file
View file

@ -0,0 +1,221 @@
# Go SDK Interceptors — Design Document
## Architecture: SEP-Compliant Chain with `interceptor/invoke`
The Go SDK follows the SEP execution model where interceptors are
first-class MCP primitives:
1. **Interceptor Server** (`mcpserver.Server`) — wraps an `mcp.Server`,
registers interceptors, and exposes them via `interceptors/list` and
`interceptor/invoke` JSON-RPC methods.
2. **Chain** (`chain.Chain`) — the SDK-level orchestrator in the
`interceptors/chain` sub-package. It holds `ChainEntry` objects
(interceptor descriptor + MCP client session) and invokes interceptors
via `interceptor/invoke` on the appropriate server.
3. **Middleware** (`gomiddleware.Middleware`) — hooks into the go-sdk's
receiving middleware pipeline, marshals payloads to `json.RawMessage`,
calls `chain.Execute()`, and applies mutated payloads back.
```
Transport (SSE / stdio / HTTP)
→ JSON-RPC decode
→ Params deserialization (json.RawMessage → typed struct)
→ Receiving middleware chain ← gomiddleware hooks in here
→ chain.Execute() → interceptor/invoke RPC (per entry)
→ Method handler (e.g. tool handler)
→ Result returned through middleware
→ chain.Execute() → interceptor/invoke RPC (per entry)
→ JSON-RPC encode
→ Transport
```
## LocalChain: In-Memory Transport
`Server.LocalChain(ctx)` creates a chain connected to the server via
in-memory transport. Under the hood it:
1. Creates an `InMemoryTransport` pair via `mcp.NewInMemoryTransports()`
2. Connects the server side via `mcp.Server.Connect(ctx, serverTransport)`
3. Creates an MCP client and connects via the client transport
4. Calls `chain.AddMCPServer(ctx, clientSession)` which discovers
interceptors via `interceptors/list`
5. Returns the ready-to-use chain
This means interceptors are invoked through the full MCP JSON-RPC
pathway — even when running in the same process — ensuring the same
behavior as remote interceptor servers.
## Capability Declaration
During initialization, a lightweight middleware intercepts the `"initialize"`
response and injects interceptor metadata into
`Capabilities.Experimental["io.modelcontextprotocol/interceptors"]`. This
follows the same pattern as the variants extension
(`io.modelcontextprotocol/server-variants`).
The capability payload includes:
- `supportedEvents` — deduplicated list of events with registered interceptors
## Request/Response Lifecycle
When a JSON-RPC request arrives, `gomiddleware.Middleware` runs:
```
0. If method is skipped (initialize, notifications/*, interceptor/*) → passthrough
1. Marshal request params to json.RawMessage
2. chain.Execute(ctx, {event, PhaseRequest, payload})
→ For each interceptor: interceptor/invoke RPC via ClientSession
3. If aborted → return JSON-RPC error
4. Unmarshal mutated payload back into request params
5. Call next handler next(ctx, method, req)
6. Marshal response result to json.RawMessage
7. chain.Execute(ctx, {event, PhaseResponse, payload})
→ For each interceptor: interceptor/invoke RPC via ClientSession
8. If aborted → return JSON-RPC error
9. Unmarshal mutated payload back into response result
10. Return result
```
### JSON-RPC Payload Model
Interceptor handlers receive `json.RawMessage` as `inv.Payload` when
invoked via `interceptor/invoke`. This is the SEP-correct behavior —
payloads are JSON at the protocol level. Handlers unmarshal, inspect or
modify, and (for mutators) set the updated JSON back on `inv.Payload`:
```go
// Validator — unmarshal, inspect, return:
raw := inv.Payload.(json.RawMessage)
var params struct{ Name string `json:"name"` }
json.Unmarshal(raw, &params)
// inspect params ...
return &ValidationResult{Valid: true}, nil
// Mutator — unmarshal, modify, marshal back:
raw := inv.Payload.(json.RawMessage)
var result map[string]any
json.Unmarshal(raw, &result)
result["redacted"] = true
data, _ := json.Marshal(result)
return &MutationResult{Modified: true, Payload: data}, nil
```
### Skipped Methods
The middleware skips the following methods to prevent recursion and
avoid intercepting lifecycle events:
- `initialize` — capability enrichment handled separately
- `notifications/initialized` — lifecycle notification
- `notifications/cancelled` — lifecycle notification
- `interceptors/list` — interceptor discovery (would cause recursion)
- `interceptor/invoke` — interceptor invocation (would cause recursion)
---
## What Is and Is Not Intercepted
### Intercepted
All JSON-RPC **method calls** routed through the server's receiving middleware:
| Method | Event |
|--------|-------|
| `tools/call` | `EventToolsCall` |
| `tools/list` | `EventToolsList` |
| `prompts/get` | `EventPromptsGet` |
| `prompts/list` | `EventPromptsList` |
| `resources/read` | `EventResourcesRead` |
| `resources/list` | `EventResourcesList` |
| `resources/subscribe` | `EventResourcesSubscribe` |
Unknown methods pass through the middleware and are intercepted normally
(the JSON-RPC method name is used as the event name).
### Not Intercepted
1. **Progress notifications.** These are JSON-RPC *notifications* sent
directly over the transport — they do not flow through `MethodHandler`
middleware.
2. **Transport-level SSE streaming.** Connection management, not
per-message streaming. Each individual method call is still a single
request → single response.
3. **JSON-RPC notifications** (e.g. `notifications/initialized`,
`notifications/cancelled`). Explicitly skipped by the middleware.
---
## Chain Execution Model
`Chain.Execute()` implements trust-boundary-aware execution per the SEP:
**Request phase** (receiving data — untrusted → trusted):
```
Validate (parallel) → Mutate (sequential)
```
Validation acts as a security gate before mutations process the data.
**Response phase** (sending data — trusted → untrusted):
```
Mutate (sequential) → Validate (parallel)
```
Mutations prepare/sanitize data, then validation verifies before sending.
Response-phase validators see the post-mutation payload.
### Validator execution
- All matching validators run in parallel (goroutines).
- A validator returning `Valid: false` with `Severity: "error"` in enforced
mode aborts the chain.
- `FailOpen: true` validators log errors and record an `InvokeResult`
for observability, but don't abort.
### Mutator execution
- Mutators run sequentially, ordered by `PriorityHint.Resolve(phase)`
(ascending), with alphabetical name tiebreak.
- Each mutator receives `json.RawMessage`, unmarshals, modifies, and sets
the updated JSON back on `inv.Payload`. The chain passes the mutated
payload from each mutator to the next.
- If any mutator fails (and is not `FailOpen`), the chain aborts.
- In `ModeAudit`, the mutated payload is not propagated to subsequent
interceptors.
### Filtering
`Chain.Execute` filters entries by:
1. Phase matches (or interceptor phase is `PhaseBoth`)
2. Event matches (exact match only; wildcard support is planned)
3. Optional name filter (`ChainExecutionParams.Interceptors`)
---
## File Map
### `interceptors/` — core types (transport-agnostic)
| File | Responsibility |
|------|---------------|
| `interceptor.go` | Interceptor interface, enums (Phase, Mode, InterceptorType, Severity), Metadata/Compat/Hook, Validator/Mutator structs and handler types, Invocation/InvocationContext/Principal, result types (ValidationResult, MutationResult) |
| `priority.go` | Priority struct, NewPriority, Resolve, MarshalJSON, UnmarshalJSON |
| `wire.go` | JSON-RPC method/event constants, wire types (ListParams, ListResult, InvokeParams, InvokeResult, InterceptorInfo) |
### `interceptors/chain/` — chain orchestrator
| File | Responsibility |
|------|---------------|
| `chain.go` | `Chain`, `ChainEntry`, `ExecutionParams`, `ExecutionResult`, `NewChain`, `AddMCPServer`, `Execute`; filtering, sorting, parallel validation, sequential mutation, trust-boundary ordering |
| `result.go` | Chain result types: ChainStatus, AbortType, AbortInfo, ValidationSummary |
### `interceptors/mcpserver/` — MCP server integration
| File | Responsibility |
|------|---------------|
| `server.go` | `Server` wrapper, interceptor registry, `LocalChain`, capability declaration, `NewStreamableHTTPHandler` |
| `rpc.go` | `handleList` and `handleInvoke` JSON-RPC method handlers |
| `events.go` | Event name constants for standard MCP methods |
### `interceptors/integrations/gomiddleware/` — middleware
| File | Responsibility |
|------|---------------|
| `middleware.go` | `Middleware` function, request/response interception, abort-to-JSON-RPC-error conversion |

76
go/sdk/doc/PERFORMANCE.md Normal file
View file

@ -0,0 +1,76 @@
# Go SDK Interceptors — Performance
Analysis of per-request costs and allocation patterns.
---
## Design Rationale: JSON-RPC Invocation
The interceptor chain follows the SEP execution model: each interceptor
is invoked via `interceptor/invoke` JSON-RPC calls through an MCP client
session. For in-process interceptors, `Server.LocalChain()` creates an
in-memory transport, so the JSON-RPC overhead is minimal (no network I/O).
Payloads are `json.RawMessage` at the protocol level. The middleware
marshals request params and response results once per phase, and
interceptor handlers unmarshal the specific fields they need.
---
## Per-Request Cost Model
Every intercepted request passes through `gomiddleware.Middleware`.
The cost depends on whether interceptors match the event.
### Fast Path (skipped methods)
The middleware skips `initialize`, `notifications/*`, `interceptors/list`,
and `interceptor/invoke` with a map lookup. Zero allocations, zero JSON
operations.
### Intercepted Path
With interceptors active, each active phase incurs:
| Step | Operation | Allocations | JSON ops |
|------|-----------|-------------|----------|
| 1 | Marshal request params / response result | 1 `json.RawMessage` | 1 marshal |
| 2 | `ChainExecutionResult` struct (with pre-allocated `Results` slice) | 1 struct + 1 slice | 0 |
| 3 | Per-interceptor `interceptor/invoke` RPC | 1 `InvokeParams` + 1 `InvokeResult` per interceptor | 1 marshal + 1 unmarshal per interceptor (handled by go-sdk JSON-RPC layer) |
| 4 | Validator execution (N=1 inline, N>1 goroutines) | 0 (N=1) / goroutines (N>1) | Handler-specific |
| 5 | Mutator payload threading | 0 | Payload passed as `json.RawMessage` between mutators |
| 6 | Unmarshal mutated payload back to params/result | 0 | 1 unmarshal (if mutated) |
### In-Memory Transport Overhead
When using `LocalChain()`, the in-memory transport uses newline-delimited
JSON over a `net.Pipe()`. Each `interceptor/invoke` call involves:
1. JSON marshal of `InvokeParams` (client side)
2. Write to pipe
3. JSON unmarshal of `InvokeParams` (server side)
4. Handler execution
5. JSON marshal of `InvokeResult` (server side)
6. Write to pipe
7. JSON unmarshal of `InvokeResult` (client side)
The JSON-RPC overhead is bounded by the payload size and is negligible
for typical MCP payloads. The in-memory pipe avoids network I/O
entirely.
### Audit Mode
Audit-mode mutators run normally but their mutated payload is not
propagated to subsequent interceptors. No deep-copy is needed — the
chain simply skips updating `FinalPayload` for audit-mode entries.
---
## Optimization Opportunities
1. **Batch invoke**: A future `interceptor/invokeValidators` method could
reduce round-trips for validators that run in parallel.
2. **Payload caching**: For validators that don't need the full payload,
a lightweight invoke variant could skip payload serialization.
3. **Connection pooling**: For remote interceptor servers, reusing
client sessions across requests avoids connection setup overhead.

View file

@ -0,0 +1,109 @@
// Example: a simple mutator interceptor that adds an "[audited]" prefix
// to every tool call result's text content.
//
// Demonstrates WithContextProvider to populate Invocation.Context with
// caller identity, which interceptor handlers can inspect.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/integrations/gomiddleware"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/mcpserver"
)
func main() {
mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "example-server",
Version: "0.1.0",
}, nil)
mcpServer.AddTool(&mcp.Tool{Name: "greet", Description: "says hello", InputSchema: map[string]any{"type": "object"}},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "hello world"}},
}, nil
},
)
// Build the mutator.
m := &interceptors.Mutator{
Metadata: interceptors.Metadata{
Name: "audit-tag",
Hook: interceptors.Hook{
Events: []string{interceptors.EventToolsCall},
Phase: interceptors.PhaseResponse,
},
Mode: interceptors.ModeEnforce,
},
Handler: func(_ context.Context, inv *interceptors.Invocation) (*interceptors.MutationResult, error) {
raw, ok := inv.Payload.(json.RawMessage)
if !ok {
return nil, fmt.Errorf("unexpected payload type %T", inv.Payload)
}
var result mcp.CallToolResult
if err := json.Unmarshal(raw, &result); err != nil {
return nil, err
}
// Use caller identity from the context provider if available.
tag := "[audited]"
if inv.Context != nil && inv.Context.Principal != nil {
tag = fmt.Sprintf("[audited by %s]", inv.Context.Principal.ID)
}
for _, c := range result.Content {
if tc, ok := c.(*mcp.TextContent); ok {
tc.Text = tag + " " + tc.Text
}
}
data, err := json.Marshal(&result)
if err != nil {
return nil, err
}
return &interceptors.MutationResult{Modified: true, Payload: json.RawMessage(data)}, nil
},
}
// Create the interceptor server (registers interceptors as discoverable
// resources via interceptors/list and interceptor/invoke).
srv := mcpserver.NewServer(mcpServer)
srv.AddInterceptor(m)
// Create a chain connected via in-memory transport.
chain, err := srv.LocalChain(context.Background())
if err != nil {
log.Fatal(err)
}
// Install middleware for automatic execution on every request/response.
// WithContextProvider populates caller identity for interceptor handlers.
mcpServer.AddReceivingMiddleware(
gomiddleware.Middleware(chain,
gomiddleware.WithContextProvider(
func(_ context.Context, _ mcp.Request) *interceptors.InvocationContext {
return &interceptors.InvocationContext{
Principal: &interceptors.Principal{
Type: "user",
ID: "example-user",
},
}
},
),
),
)
handler := mcpserver.NewStreamableHTTPHandler(srv, nil)
log.Println("listening on :8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatal(err)
}
}

View file

@ -0,0 +1,106 @@
// Example: a simple validator interceptor that rejects tool calls
// to a tool named "dangerous_tool".
//
// Demonstrates WithContextProvider to populate Invocation.Context with
// caller identity, which interceptor handlers can inspect.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/integrations/gomiddleware"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/mcpserver"
)
func main() {
mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "example-server",
Version: "0.1.0",
}, nil)
mcpServer.AddTool(&mcp.Tool{Name: "echo", Description: "echoes input", InputSchema: map[string]any{"type": "object"}},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("you said: %s", req.Params.Arguments)}},
}, nil
},
)
// Build the validator.
v := &interceptors.Validator{
Metadata: interceptors.Metadata{
Name: "block-dangerous-tool",
Hook: interceptors.Hook{
Events: []string{interceptors.EventToolsCall},
Phase: interceptors.PhaseRequest,
},
Mode: interceptors.ModeEnforce,
},
Handler: func(_ context.Context, inv *interceptors.Invocation) (*interceptors.ValidationResult, error) {
raw, ok := inv.Payload.(json.RawMessage)
if !ok {
return nil, fmt.Errorf("unexpected payload type %T", inv.Payload)
}
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &params); err != nil {
return nil, err
}
if params.Name == "dangerous_tool" {
reason := "dangerous_tool is not allowed"
if inv.Context != nil && inv.Context.Principal != nil {
reason = fmt.Sprintf("dangerous_tool is not allowed for %s", inv.Context.Principal.ID)
}
return &interceptors.ValidationResult{
Valid: false,
Messages: []interceptors.ValidationMessage{
{Message: reason, Severity: interceptors.SeverityError},
},
}, nil
}
return &interceptors.ValidationResult{Valid: true}, nil
},
}
// Create the interceptor server (registers interceptors as discoverable
// resources via interceptors/list and interceptor/invoke).
srv := mcpserver.NewServer(mcpServer)
srv.AddInterceptor(v)
// Create a chain connected via in-memory transport.
chain, err := srv.LocalChain(context.Background())
if err != nil {
log.Fatal(err)
}
// Install middleware for automatic execution on every request/response.
// WithContextProvider populates caller identity for interceptor handlers.
mcpServer.AddReceivingMiddleware(
gomiddleware.Middleware(chain,
gomiddleware.WithContextProvider(
func(_ context.Context, _ mcp.Request) *interceptors.InvocationContext {
return &interceptors.InvocationContext{
Principal: &interceptors.Principal{
Type: "user",
ID: "example-user",
},
}
},
),
),
)
handler := mcpserver.NewStreamableHTTPHandler(srv, nil)
log.Println("listening on :8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatal(err)
}
}

View file

@ -1,4 +1,22 @@
module github.com/modelcontextprotocol/ext-interceptors/go/sdk module github.com/modelcontextprotocol/ext-interceptors/go/sdk
go 1.23.0 go 1.24.0
toolchain go1.24.3
require (
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

30
go/sdk/go.sum Normal file
View file

@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,439 @@
// Copyright 2025 The MCP Interceptors Authors. All rights reserved.
// Use of this source code is governed by an Apache-2.0
// license that can be found in the LICENSE file.
package chain
import (
"context"
"encoding/json"
"log/slog"
"slices"
"sort"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors"
)
// ChainEntry pairs an interceptor descriptor with the MCP client
// session that hosts it.
type ChainEntry struct {
Interceptor interceptors.InterceptorInfo
Server *mcp.ClientSession
}
// Chain is the SEP-compliant interceptor chain orchestrator. It holds
// ChainEntry objects (interceptor descriptors + MCP server connections),
// discovers interceptors via interceptors/list, and invokes them via
// interceptor/invoke on the appropriate server.
type Chain struct {
mu sync.Mutex
entries []ChainEntry
logger *slog.Logger
}
// ChainOption configures a Chain.
type ChainOption func(*Chain)
// WithChainLogger sets the logger for chain execution.
// If not set, slog.Default() is used.
func WithChainLogger(l *slog.Logger) ChainOption {
return func(c *Chain) {
c.logger = l
}
}
// NewChain creates a new Chain with optional configuration.
func NewChain(opts ...ChainOption) *Chain {
c := &Chain{}
for _, opt := range opts {
opt(c)
}
if c.logger == nil {
c.logger = slog.Default()
}
return c
}
// ExecutionParams configures a chain execution run. These are
// SDK-level types used by Chain.Execute, not wire-protocol types.
type ExecutionParams struct {
Event string `json:"event"`
Phase interceptors.InterceptionPhase `json:"phase"`
Payload json.RawMessage `json:"payload"`
Interceptors []string `json:"interceptors,omitempty"` // optional: restrict to named interceptors
Config map[string]map[string]any `json:"config,omitempty"` // optional: per-interceptor config
TimeoutMs int64 `json:"timeoutMs,omitempty"`
Context *interceptors.InvocationContext `json:"context,omitempty"`
}
// ExecutionResult aggregates results from executing the full
// interceptor chain via interceptor/invoke RPCs.
type ExecutionResult struct {
Status ChainStatus `json:"status"`
Event string `json:"event"`
Phase interceptors.InterceptionPhase `json:"phase"`
Results []interceptors.InvokeResult `json:"results"`
FinalPayload json.RawMessage `json:"finalPayload,omitempty"`
ValidationSummary ValidationSummary `json:"validationSummary"`
TotalDurationMs int64 `json:"totalDurationMs"`
AbortedAt []AbortInfo `json:"abortedAt,omitempty"`
}
// AddMCPServer discovers interceptors from an MCP server via
// interceptors/list and adds entries for each. The client session's
// transport determines how interceptor/invoke calls are made
// (in-memory, stdio, HTTP, etc.).
func (c *Chain) AddMCPServer(ctx context.Context, cs *mcp.ClientSession) error {
var result interceptors.ListResult
if err := cs.CallCustom(ctx, interceptors.MethodList, nil, &result); err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
for _, info := range result.Interceptors {
c.entries = append(c.entries, ChainEntry{
Interceptor: info,
Server: cs,
})
}
return nil
}
// Execute runs the chain for a given event and phase per the SEP
// execution model:
//
// - Filter entries by event + phase
// - Separate into validators and mutators
// - Sort mutators by priorityHint (ascending, alphabetical tiebreak)
// - Request phase: validate (parallel) then mutate (sequential)
// - Response phase: mutate (sequential) then validate (parallel)
// - Call interceptor/invoke via CallCustom for each entry
// - For mutators: pass mutated payload from previous to next
// - Handle abort, timeout, fail-open
func (c *Chain) Execute(ctx context.Context, params *ExecutionParams) (*ExecutionResult, error) {
c.mu.Lock()
entries := make([]ChainEntry, len(c.entries))
copy(entries, c.entries)
c.mu.Unlock()
// Apply timeout if specified.
if params.TimeoutMs > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(params.TimeoutMs)*time.Millisecond)
defer cancel()
}
start := time.Now()
// Filter entries by event + phase, respecting optional name filter.
nameFilter := make(map[string]bool, len(params.Interceptors))
for _, n := range params.Interceptors {
nameFilter[n] = true
}
var validators []ChainEntry
var mutators []ChainEntry
for _, e := range entries {
if len(nameFilter) > 0 && !nameFilter[e.Interceptor.Name] {
continue
}
if !matchesPhase(e.Interceptor.Hook.Phase, params.Phase) {
continue
}
if !slices.Contains(e.Interceptor.Hook.Events, params.Event) {
continue
}
switch e.Interceptor.Type {
case interceptors.TypeValidation:
validators = append(validators, e)
case interceptors.TypeMutation:
mutators = append(mutators, e)
default:
c.logger.Warn("unknown interceptor type, skipping",
"interceptor", e.Interceptor.Name,
"type", e.Interceptor.Type,
)
}
}
// Sort mutators by priority (ascending), alphabetical tiebreak.
sort.Slice(mutators, func(i, j int) bool {
pi := mutators[i].Interceptor.PriorityHint.Resolve(params.Phase)
pj := mutators[j].Interceptor.PriorityHint.Resolve(params.Phase)
if pi != pj {
return pi < pj
}
return mutators[i].Interceptor.Name < mutators[j].Interceptor.Name
})
cr := &ExecutionResult{
Event: params.Event,
Phase: params.Phase,
Results: make([]interceptors.InvokeResult, 0, len(validators)+len(mutators)),
}
// Empty chain fast path.
if len(validators) == 0 && len(mutators) == 0 {
cr.Status = ChainSuccess
cr.TotalDurationMs = time.Since(start).Milliseconds()
return cr, nil
}
// Trust-boundary-aware ordering.
switch params.Phase {
case interceptors.PhaseRequest:
// Request (receiving): validate → mutate
c.runValidators(ctx, params, validators, cr)
if len(cr.AbortedAt) > 0 {
cr.Status = ChainValidationFailed
cr.TotalDurationMs = time.Since(start).Milliseconds()
return cr, nil
}
if ctx.Err() != nil {
c.timeoutResult(cr, start)
return cr, nil
}
c.runMutators(ctx, params, mutators, cr)
if len(cr.AbortedAt) > 0 {
cr.Status = ChainMutationFailed
cr.TotalDurationMs = time.Since(start).Milliseconds()
return cr, nil
}
case interceptors.PhaseResponse:
// Response (sending): mutate → validate
c.runMutators(ctx, params, mutators, cr)
if len(cr.AbortedAt) > 0 {
cr.Status = ChainMutationFailed
cr.TotalDurationMs = time.Since(start).Milliseconds()
return cr, nil
}
if ctx.Err() != nil {
c.timeoutResult(cr, start)
return cr, nil
}
// Validators must see the post-mutation payload.
valParams := *params
if cr.FinalPayload != nil {
valParams.Payload = cr.FinalPayload
}
c.runValidators(ctx, &valParams, validators, cr)
if len(cr.AbortedAt) > 0 {
cr.Status = ChainValidationFailed
cr.TotalDurationMs = time.Since(start).Milliseconds()
return cr, nil
}
}
cr.Status = ChainSuccess
cr.TotalDurationMs = time.Since(start).Milliseconds()
return cr, nil
}
// runValidators invokes all validators in parallel via interceptor/invoke.
func (c *Chain) runValidators(
ctx context.Context,
params *ExecutionParams,
validators []ChainEntry,
cr *ExecutionResult,
) {
if len(validators) == 0 {
return
}
var (
mu sync.Mutex
wg sync.WaitGroup
)
for _, v := range validators {
wg.Add(1)
go func(v ChainEntry) {
defer wg.Done()
result := c.callInvoke(ctx, params, v)
mu.Lock()
c.recordValidation(v, result, cr)
mu.Unlock()
}(v)
}
wg.Wait()
}
// recordValidation processes a validator's invoke result and updates the chain result.
func (c *Chain) recordValidation(entry ChainEntry, result invokeOutcome, cr *ExecutionResult) {
if result.err != nil {
c.logger.Warn("validator invoke error",
"interceptor", entry.Interceptor.Name,
"error", result.err,
)
cr.Results = append(cr.Results, interceptors.InvokeResult{
Interceptor: entry.Interceptor.Name,
Type: interceptors.TypeValidation,
Phase: cr.Phase,
})
if !entry.Interceptor.FailOpen {
cr.AbortedAt = append(cr.AbortedAt, AbortInfo{
Interceptor: entry.Interceptor.Name,
Reason: result.err.Error(),
Type: AbortValidation,
Phase: string(cr.Phase),
})
}
return
}
cr.Results = append(cr.Results, result.result)
// Tally validation summary and check for abort in a single pass.
if result.result.Validation != nil {
shouldAbort := entry.Interceptor.Mode != interceptors.ModeAudit && !result.result.Validation.Valid
aborted := false
for _, msg := range result.result.Validation.Messages {
switch msg.Severity {
case interceptors.SeverityError:
cr.ValidationSummary.Errors++
if shouldAbort && !aborted {
cr.AbortedAt = append(cr.AbortedAt, AbortInfo{
Interceptor: entry.Interceptor.Name,
Reason: msg.Message,
Type: AbortValidation,
Phase: string(cr.Phase),
})
aborted = true
}
case interceptors.SeverityWarn:
cr.ValidationSummary.Warnings++
case interceptors.SeverityInfo:
cr.ValidationSummary.Infos++
}
}
}
}
// runMutators invokes mutators sequentially via interceptor/invoke,
// passing the mutated payload from each mutator to the next.
func (c *Chain) runMutators(
ctx context.Context,
params *ExecutionParams,
mutators []ChainEntry,
cr *ExecutionResult,
) {
if len(mutators) == 0 {
return
}
currentPayload := params.Payload
mutated := false
for _, m := range mutators {
if ctx.Err() != nil {
cr.AbortedAt = append(cr.AbortedAt, AbortInfo{
Reason: "context cancelled during mutation chain",
Type: AbortTimeout,
Phase: string(params.Phase),
})
return
}
// Build invoke params with current payload.
mutParams := *params
mutParams.Payload = currentPayload
result := c.callInvoke(ctx, &mutParams, m)
if result.err != nil {
c.logger.Warn("mutator invoke error",
"interceptor", m.Interceptor.Name,
"error", result.err,
)
cr.Results = append(cr.Results, interceptors.InvokeResult{
Interceptor: m.Interceptor.Name,
Type: interceptors.TypeMutation,
Phase: params.Phase,
})
if !m.Interceptor.FailOpen {
cr.AbortedAt = append(cr.AbortedAt, AbortInfo{
Interceptor: m.Interceptor.Name,
Reason: result.err.Error(),
Type: AbortMutation,
Phase: string(params.Phase),
})
return
}
continue
}
cr.Results = append(cr.Results, result.result)
// For audit-mode mutators, don't apply the mutated payload.
if m.Interceptor.Mode == interceptors.ModeAudit {
continue
}
// Apply mutated payload for the next mutator.
if result.result.Payload != nil {
currentPayload = result.result.Payload
mutated = true
}
}
if mutated {
cr.FinalPayload = currentPayload
}
}
// invokeOutcome wraps the result of a single interceptor/invoke call.
type invokeOutcome struct {
result interceptors.InvokeResult
err error
}
// callInvoke calls interceptor/invoke on the appropriate server for
// a single chain entry.
func (c *Chain) callInvoke(
ctx context.Context,
params *ExecutionParams,
entry ChainEntry,
) invokeOutcome {
invokeParams := &interceptors.InvokeParams{
Name: entry.Interceptor.Name,
Event: params.Event,
Phase: params.Phase,
Payload: params.Payload,
Context: params.Context,
}
// Apply per-interceptor config if provided.
if cfg, ok := params.Config[entry.Interceptor.Name]; ok {
invokeParams.Config = cfg
}
var result interceptors.InvokeResult
err := entry.Server.CallCustom(ctx, interceptors.MethodInvoke, invokeParams, &result)
if err != nil {
return invokeOutcome{err: err}
}
return invokeOutcome{result: result}
}
// timeoutResult sets the chain result to timeout status.
func (c *Chain) timeoutResult(cr *ExecutionResult, start time.Time) {
cr.Status = ChainTimeout
cr.TotalDurationMs = time.Since(start).Milliseconds()
cr.AbortedAt = append(cr.AbortedAt, AbortInfo{
Reason: "chain execution timeout exceeded",
Type: AbortTimeout,
Phase: string(cr.Phase),
})
}
// matchesPhase checks if an interceptor's configured phase covers the target
// phase. An interceptor with PhaseBoth matches any target phase.
func matchesPhase(interceptorPhase, targetPhase interceptors.InterceptionPhase) bool {
return interceptorPhase == interceptors.PhaseBoth || interceptorPhase == targetPhase
}

View file

@ -0,0 +1,239 @@
// Copyright 2025 The MCP Interceptors Authors. All rights reserved.
// Use of this source code is governed by an Apache-2.0
// license that can be found in the LICENSE file.
package chain_test
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/chain"
)
// setupChainWithInterceptors creates an MCP server with the given
// interceptors registered via custom methods, connects a chain via
// in-memory transport, and returns the chain ready for testing.
func setupChainWithInterceptors(t *testing.T, is ...interceptors.Interceptor) *chain.Chain {
t.Helper()
mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "chain-test-server",
Version: "0.1.0",
}, nil)
// Register interceptors/list and interceptor/invoke handlers.
registerInterceptorMethods(mcpServer, is)
serverTransport, clientTransport := mcp.NewInMemoryTransports()
ss, err := mcpServer.Connect(context.Background(), serverTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { ss.Close() })
client := mcp.NewClient(&mcp.Implementation{
Name: "chain-test-client",
Version: "0.1.0",
}, nil)
cs, err := client.Connect(context.Background(), clientTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { cs.Close() })
ch := chain.NewChain(chain.WithChainLogger(slog.Default()))
err = ch.AddMCPServer(context.Background(), cs)
require.NoError(t, err)
return ch
}
// registerInterceptorMethods adds interceptors/list and interceptor/invoke
// custom methods to the server, backed by the given interceptor list.
func registerInterceptorMethods(server *mcp.Server, is []interceptors.Interceptor) {
server.AddCustomMethod(interceptors.MethodList,
func(_ context.Context, _ *mcp.ServerSession, raw json.RawMessage) (any, error) {
var params interceptors.ListParams
if len(raw) > 0 {
if err := json.Unmarshal(raw, &params); err != nil {
return nil, err
}
}
infos := make([]interceptors.InterceptorInfo, 0, len(is))
for _, i := range is {
if params.Event != "" {
match := false
for _, e := range i.GetMetadata().Hook.Events {
if e == params.Event {
match = true
break
}
}
if !match {
continue
}
}
infos = append(infos, interceptors.InfoFromInterceptor(i))
}
return &interceptors.ListResult{Interceptors: infos}, nil
},
)
server.AddCustomMethod(interceptors.MethodInvoke,
func(ctx context.Context, _ *mcp.ServerSession, raw json.RawMessage) (any, error) {
var params interceptors.InvokeParams
if err := json.Unmarshal(raw, &params); err != nil {
return nil, err
}
var target interceptors.Interceptor
for _, i := range is {
if i.GetMetadata().Name == params.Name {
target = i
break
}
}
if target == nil {
return nil, fmt.Errorf("interceptor %q not found", params.Name)
}
inv := &interceptors.Invocation{
Event: params.Event,
Phase: params.Phase,
Payload: params.Payload,
Config: params.Config,
Context: params.Context,
}
result := &interceptors.InvokeResult{
Interceptor: params.Name,
Type: target.GetType(),
Phase: params.Phase,
}
switch v := target.(type) {
case *interceptors.Validator:
vr, err := v.Handler(ctx, inv)
if err != nil {
return nil, err
}
result.Validation = vr
case *interceptors.Mutator:
mr, err := v.Handler(ctx, inv)
if err != nil {
return nil, err
}
result.Mutation = mr
result.Payload = mr.Payload
}
return result, nil
},
)
}
func TestChain_FailOpenRecordsExecutionResult(t *testing.T) {
t.Parallel()
t.Run("fail-open validator error is recorded", func(t *testing.T) {
t.Parallel()
failOpenValidator := &interceptors.Validator{
Metadata: interceptors.Metadata{
Name: "fo-validator",
Hook: interceptors.Hook{
Events: []string{"test/event"},
Phase: interceptors.PhaseRequest,
},
Mode: interceptors.ModeEnforce,
FailOpen: true,
},
Handler: func(_ context.Context, _ *interceptors.Invocation) (*interceptors.ValidationResult, error) {
return nil, fmt.Errorf("transient failure")
},
}
passingValidator := &interceptors.Validator{
Metadata: interceptors.Metadata{
Name: "passing-validator",
Hook: interceptors.Hook{
Events: []string{"test/event"},
Phase: interceptors.PhaseRequest,
},
Mode: interceptors.ModeEnforce,
},
Handler: func(_ context.Context, _ *interceptors.Invocation) (*interceptors.ValidationResult, error) {
return &interceptors.ValidationResult{Valid: true}, nil
},
}
ch := setupChainWithInterceptors(t, failOpenValidator, passingValidator)
payload, _ := json.Marshal(map[string]any{"value": "hello"})
cr, err := ch.Execute(context.Background(), &chain.ExecutionParams{
Event: "test/event",
Phase: interceptors.PhaseRequest,
Payload: payload,
})
require.NoError(t, err)
// Chain should succeed — fail-open doesn't abort.
assert.Equal(t, chain.ChainSuccess, cr.Status)
assert.Empty(t, cr.AbortedAt)
// Both interceptors should have a result entry.
require.Len(t, cr.Results, 2)
})
t.Run("fail-open mutator error is recorded", func(t *testing.T) {
t.Parallel()
failOpenMutator := &interceptors.Mutator{
Metadata: interceptors.Metadata{
Name: "fo-mutator",
Hook: interceptors.Hook{
Events: []string{"test/event"},
Phase: interceptors.PhaseResponse,
},
Mode: interceptors.ModeEnforce,
FailOpen: true,
PriorityHint: interceptors.NewPriority(10),
},
Handler: func(_ context.Context, _ *interceptors.Invocation) (*interceptors.MutationResult, error) {
return nil, fmt.Errorf("transient failure")
},
}
passingMutator := &interceptors.Mutator{
Metadata: interceptors.Metadata{
Name: "passing-mutator",
Hook: interceptors.Hook{
Events: []string{"test/event"},
Phase: interceptors.PhaseResponse,
},
Mode: interceptors.ModeEnforce,
PriorityHint: interceptors.NewPriority(20),
},
Handler: func(_ context.Context, _ *interceptors.Invocation) (*interceptors.MutationResult, error) {
return &interceptors.MutationResult{Modified: false}, nil
},
}
ch := setupChainWithInterceptors(t, failOpenMutator, passingMutator)
payload, _ := json.Marshal(map[string]any{"value": "hello"})
cr, err := ch.Execute(context.Background(), &chain.ExecutionParams{
Event: "test/event",
Phase: interceptors.PhaseResponse,
Payload: payload,
})
require.NoError(t, err)
// Chain should succeed — fail-open doesn't abort.
assert.Equal(t, chain.ChainSuccess, cr.Status)
assert.Empty(t, cr.AbortedAt)
// Both interceptors should have a result entry.
require.Len(t, cr.Results, 2)
})
}

View file

@ -0,0 +1,39 @@
// Copyright 2025 The MCP Interceptors Authors. All rights reserved.
// Use of this source code is governed by an Apache-2.0
// license that can be found in the LICENSE file.
// Package chain provides the SEP-compliant interceptor chain
// orchestrator. It discovers interceptors from MCP servers via
// "interceptors/list" and invokes them via "interceptor/invoke",
// implementing trust-boundary-aware execution ordering.
//
// # Execution Model
//
// [Chain.Execute] filters registered interceptors by event and phase,
// separates them into validators and mutators, and runs them in the
// SEP-defined order:
//
// Request phase (untrusted → trusted):
//
// Validate (parallel) → Mutate (sequential)
//
// Response phase (trusted → untrusted):
//
// Mutate (sequential) → Validate (parallel)
//
// # Usage
//
// A [Chain] is created with [NewChain] and populated by adding MCP
// servers via [Chain.AddMCPServer]:
//
// chain := chain.NewChain()
// chain.AddMCPServer(ctx, clientSession)
// mcpServer.AddReceivingMiddleware(
// gomiddleware.Middleware(chain),
// )
//
// For convenience, [mcpserver.Server.LocalChain] creates a chain
// pre-configured with a local in-process MCP server:
//
// chain, err := srv.LocalChain(ctx)
package chain

View file

@ -0,0 +1,39 @@
// Copyright 2025 The MCP Interceptors Authors. All rights reserved.
// Use of this source code is governed by an Apache-2.0
// license that can be found in the LICENSE file.
package chain
// ChainStatus describes the outcome of a full interceptor chain execution.
type ChainStatus string
const (
ChainSuccess ChainStatus = "success"
ChainValidationFailed ChainStatus = "validation_failed"
ChainMutationFailed ChainStatus = "mutation_failed"
ChainTimeout ChainStatus = "timeout"
)
// AbortType classifies the reason an interceptor chain was aborted.
type AbortType string
const (
AbortValidation AbortType = "validation"
AbortMutation AbortType = "mutation"
AbortTimeout AbortType = "timeout"
)
// ValidationSummary counts validation outcomes.
type ValidationSummary struct {
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Infos int `json:"infos"`
}
// AbortInfo describes where and why a chain was aborted.
type AbortInfo struct {
Interceptor string `json:"interceptor"`
Reason string `json:"reason"`
Type AbortType `json:"type"`
Phase string `json:"phase,omitempty"` // phase at which the abort occurred
}

149
go/sdk/interceptors/doc.go Normal file
View file

@ -0,0 +1,149 @@
// Copyright 2025 The MCP Interceptors Authors. All rights reserved.
// Use of this source code is governed by an Apache-2.0
// license that can be found in the LICENSE file.
// Package interceptors defines the core types for the MCP interceptor
// framework: interceptor descriptors ([Validator], [Mutator]), wire
// protocol types ([InvokeParams], [InvokeResult]), result types
// ([ValidationResult], [MutationResult]), and supporting enums.
//
// This package is transport-agnostic — it has no dependency on the
// MCP SDK's server or client types. The chain orchestrator lives in
// the [interceptors/chain] sub-package, MCP server integration in
// [interceptors/mcpserver], and middleware in
// [interceptors/integrations/gomiddleware].
//
// # Usage with MCP
//
// Create an interceptor [mcpserver.Server], register interceptors,
// then use [mcpserver.Server.LocalChain] to get a chain for
// middleware:
//
// srv := mcpserver.NewServer(mcpServer)
// srv.AddInterceptor(myValidator)
// srv.AddInterceptor(myMutator)
//
// chain, err := srv.LocalChain(ctx)
// mcpServer.AddReceivingMiddleware(
// gomiddleware.Middleware(chain),
// )
//
// # Validators
//
// A [Validator] inspects the payload and decides whether the request
// or response should proceed. All validators for a given event run
// in parallel. If any validator in enforced mode ([ModeEnforce])
// returns an error-severity message, the chain aborts before any
// mutators run. Only error-severity messages cause an abort; warn
// and info findings are recorded in the chain execution result but
// do not block the chain.
//
// Interceptor handlers receive [json.RawMessage] payloads when
// invoked via interceptor/invoke:
//
// v := &interceptors.Validator{
// Metadata: interceptors.Metadata{
// Name: "block-dangerous-tool",
// Hook: interceptors.Hook{
// Events: []string{"tools/call"},
// Phase: interceptors.PhaseRequest,
// },
// Mode: interceptors.ModeEnforce,
// },
// Handler: func(ctx context.Context, inv *interceptors.Invocation) (*interceptors.ValidationResult, error) {
// raw := inv.Payload.(json.RawMessage)
// var params struct{ Name string `json:"name"` }
// json.Unmarshal(raw, &params)
// // inspect params ...
// return &interceptors.ValidationResult{Valid: true}, nil
// },
// }
//
// # Mutators
//
// A [Mutator] transforms the payload. Mutators run sequentially in
// priority order (see [Priority]). Each mutator receives the payload
// as [json.RawMessage], unmarshals it, modifies it, and sets the
// updated JSON back on inv.Payload. If any mutator fails (and is not
// configured with FailOpen), the chain aborts. FailOpen mutators
// record an [InvokeResult] (with the error captured) for
// observability but do not block.
//
// m := &interceptors.Mutator{
// Metadata: interceptors.Metadata{
// Name: "redact-pii",
// Hook: interceptors.Hook{
// Events: []string{"tools/call"},
// Phase: interceptors.PhaseResponse,
// },
// Mode: interceptors.ModeEnforce,
// },
// Handler: func(ctx context.Context, inv *interceptors.Invocation) (*interceptors.MutationResult, error) {
// raw := inv.Payload.(json.RawMessage)
// var result map[string]any
// json.Unmarshal(raw, &result)
// // modify result ...
// data, _ := json.Marshal(result)
// return &interceptors.MutationResult{Modified: true, Payload: data}, nil
// },
// }
//
// # Execution Order
//
// The chain execution order depends on direction:
//
// Request phase (untrusted → trusted):
//
// Validate (parallel) → Mutate (sequential)
//
// Response phase (trusted → untrusted):
//
// Mutate (sequential) → Validate (parallel)
//
// Validators act as a security gate on the trust boundary side,
// while mutators prepare or sanitize data on the other side.
//
// # Modes and FailOpen
//
// Each interceptor has a [Mode] that controls what happens with
// successful results, and a FailOpen flag that controls what happens
// when the handler returns a Go error. These are orthogonal:
//
// - [ModeEnforce]: fully enforced — validation failures block,
// mutations are applied.
// - [ModeAudit]: the handler runs and results are recorded, but
// validation findings do not block and mutated payloads are
// not propagated to subsequent interceptors.
//
// FailOpen (default false) controls crash resilience:
//
// - FailOpen=false: a handler error aborts the chain. An
// [InvokeResult] and a [chain.AbortInfo] are both recorded.
// - FailOpen=true: a handler error is logged and an
// [InvokeResult] is recorded, but the chain continues.
//
// Note that [ModeAudit] does NOT imply FailOpen. Audit mode only
// suppresses enforcement of successful results (validation findings
// and mutations). If the handler itself returns an error and
// FailOpen is false, the chain still aborts. For truly safe
// observation-only interceptors, set both ModeAudit and FailOpen:
//
// Metadata: interceptors.Metadata{
// Mode: interceptors.ModeAudit,
// FailOpen: true,
// }
//
// Behavior matrix for validators:
//
// Mode=Enforce, FailOpen=false → error aborts, Valid=false+SeverityError aborts
// Mode=Enforce, FailOpen=true → error continues, Valid=false+SeverityError aborts
// Mode=Audit, FailOpen=false → error aborts, findings recorded only
// Mode=Audit, FailOpen=true → error continues, findings recorded only
//
// Behavior matrix for mutators:
//
// Mode=Enforce, FailOpen=false → error aborts, mutations applied
// Mode=Enforce, FailOpen=true → error continues, mutations applied
// Mode=Audit, FailOpen=false → error aborts, mutations not propagated
// Mode=Audit, FailOpen=true → error continues, mutations not propagated
package interceptors

View file

@ -0,0 +1,33 @@
// Copyright 2025 The MCP Interceptors Authors. All rights reserved.
// Use of this source code is governed by an Apache-2.0
// license that can be found in the LICENSE file.
// Package gomiddleware provides an optional MCP middleware that
// automatically executes interceptors for every matching
// request/response. It uses the [chain.Chain] from the
// [interceptors/chain] package, which invokes interceptors via
// interceptor/invoke RPCs per the SEP execution model.
//
// The middleware is installed on an [mcp.Server] via
// [mcp.Server.AddReceivingMiddleware]. It marshals request params
// and response results to [json.RawMessage], calls
// [chain.Chain.Execute], and applies any mutated payloads back to
// the request or response.
//
// # Usage
//
// // Create interceptor server (registers interceptors as resources)
// srv := mcpserver.NewServer(mcpServer)
// srv.AddInterceptor(myValidator)
// srv.AddInterceptor(myMutator)
//
// // Create chain via in-memory transport
// chain, err := srv.LocalChain(ctx)
//
// // Install middleware for automatic execution
// mcpServer.AddReceivingMiddleware(
// gomiddleware.Middleware(chain,
// gomiddleware.WithLogger(logger),
// ),
// )
package gomiddleware

View file

@ -0,0 +1,75 @@
// Copyright 2025 The MCP Interceptors Authors. All rights reserved.
// Use of this source code is governed by an Apache-2.0
// license that can be found in the LICENSE file.
package gomiddleware
import (
"encoding/json"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/chain"
)
type validationErrorData struct {
ValidationErrors []chain.AbortInfo `json:"validationErrors"`
}
type mutationErrorData struct {
FailedInterceptor string `json:"failedInterceptor"`
Reason string `json:"reason"`
}
type timeoutErrorData struct {
Interceptor string `json:"interceptor"`
TimeoutMs int64 `json:"timeoutMs,omitempty"`
Phase string `json:"phase"`
}
const codeServerError = -32000
// abortToJSONRPCError converts a list of abort infos into a *jsonrpc.Error.
func abortToJSONRPCError(aborts []chain.AbortInfo) *jsonrpc.Error {
first := aborts[0]
var code int64
switch first.Type {
case chain.AbortValidation:
code = jsonrpc.CodeInvalidParams
case chain.AbortTimeout:
code = codeServerError
default:
code = jsonrpc.CodeInternalError
}
var data json.RawMessage
switch first.Type {
case chain.AbortValidation:
d, _ := json.Marshal(validationErrorData{ValidationErrors: aborts})
data = d
case chain.AbortTimeout:
d, _ := json.Marshal(timeoutErrorData{
Interceptor: first.Interceptor,
Phase: first.Phase,
})
data = d
default:
d, _ := json.Marshal(mutationErrorData{
FailedInterceptor: first.Interceptor,
Reason: first.Reason,
})
data = d
}
msg := "interceptor abort: " + first.Reason
if len(aborts) > 1 {
msg = "interceptor abort: multiple validation failures"
}
return &jsonrpc.Error{
Code: code,
Message: msg,
Data: data,
}
}

Some files were not shown because too many files have changed in this diff Show more