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:
parent
45f9e0ef27
commit
c9900f02ab
273 changed files with 62908 additions and 19 deletions
19
.githooks/pre-commit
Executable file
19
.githooks/pre-commit
Executable 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 ./...
|
||||
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go: ["1.23", "1.24", "1.25"]
|
||||
go: ["1.24", "1.25"]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,9 @@
|
|||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
# Go example binaries (compiled output only, not source directories)
|
||||
/go/sdk/examples/mutator/mutator
|
||||
/go/sdk/examples/validator/validator
|
||||
|
||||
# Build results
|
||||
[Bb]in/
|
||||
|
|
|
|||
1731
docs/sep.md
Normal file
1731
docs/sep.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,59 @@
|
|||
# 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, ¶ms)
|
||||
// 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
39
go/sdk/doc/CONFORMANCE.md
Normal 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
221
go/sdk/doc/DESIGN.md
Normal 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, ¶ms)
|
||||
// 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
76
go/sdk/doc/PERFORMANCE.md
Normal 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.
|
||||
109
go/sdk/examples/mutator/main.go
Normal file
109
go/sdk/examples/mutator/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
106
go/sdk/examples/validator/main.go
Normal file
106
go/sdk/examples/validator/main.go
Normal 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, ¶ms); 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,22 @@
|
|||
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
30
go/sdk/go.sum
Normal 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=
|
||||
439
go/sdk/interceptors/chain/chain.go
Normal file
439
go/sdk/interceptors/chain/chain.go
Normal 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
|
||||
}
|
||||
239
go/sdk/interceptors/chain/chain_test.go
Normal file
239
go/sdk/interceptors/chain/chain_test.go
Normal 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, ¶ms); 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, ¶ms); 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)
|
||||
})
|
||||
}
|
||||
39
go/sdk/interceptors/chain/doc.go
Normal file
39
go/sdk/interceptors/chain/doc.go
Normal 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
|
||||
39
go/sdk/interceptors/chain/result.go
Normal file
39
go/sdk/interceptors/chain/result.go
Normal 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
149
go/sdk/interceptors/doc.go
Normal 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, ¶ms)
|
||||
// // 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
|
||||
33
go/sdk/interceptors/integrations/gomiddleware/doc.go
Normal file
33
go/sdk/interceptors/integrations/gomiddleware/doc.go
Normal 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
|
||||
75
go/sdk/interceptors/integrations/gomiddleware/errors.go
Normal file
75
go/sdk/interceptors/integrations/gomiddleware/errors.go
Normal 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
Loading…
Reference in a new issue