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
|
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
3
.gitignore
vendored
|
|
@ -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
1731
docs/sep.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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, ¶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
|
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