Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.23", "1.24", "1.25"]
go: ["1.24", "1.25"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you include go version 1.26 too

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, will add!

steps:
- name: Check out code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*.dll
*.so
*.dylib
mutator
validator

# Build results
[Bb]in/
Expand Down
56 changes: 55 additions & 1 deletion go/sdk/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,57 @@
# 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).

## Quick Start

```go
mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "my-server",
Version: "0.1.0",
}, nil)

// Wrap with interceptor support.
srv := interceptors.NewServer(mcpServer,
// Optional Context Provider
interceptors.WithContextProvider(
func(_ context.Context, _ mcp.Request) *interceptors.InvocationContext {
return &interceptors.InvocationContext{
Principal: &interceptors.Principal{Type: "user", ID: "alice"},
}
},
),
)

// Register a validator that blocks dangerous tool calls.
srv.AddInterceptor(&interceptors.Validator{
Metadata: interceptors.Metadata{
Name: "block-dangerous",
Events: []string{interceptors.EventToolsCall},
Phase: interceptors.PhaseRequest,
Mode: interceptors.ModeOn,
},
Handler: func(_ context.Context, inv *interceptors.Invocation) (*interceptors.ValidationResult, error) {
// validate the request...
return &interceptors.ValidationResult{Valid: true}, nil
},
})

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
```
34 changes: 34 additions & 0 deletions go/sdk/doc/CONFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# SEP Conformance

Status of this Go SDK implementation against the
[SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763)
interceptor proposal.

## Implemented

| Area | Notes |
|------|-------|
| Validation interceptors | Parallel execution, severity-based blocking, fail-open support |
| Mutation interceptors | Sequential execution, priority ordering, atomic payload updates |
| Interceptor metadata | Name, version, description, events, phase, priorityHint (polymorphic JSON), compat, configSchema, mode, failOpen, timeout |
| Event names | Constants for all standard server-side MCP methods; JSON-RPC method names used directly as event names |
| Unified result envelope | ValidationResult, MutationResult, ExecutionResult with base envelope fields |
| Chain result | ChainResult with status, results, finalPayload, validationSummary, abortedAt |
| JSON-RPC error mapping | Typed error data structs: -32602 for validation, -32603 for mutation, -32000 for timeout |
| Trust-boundary execution order | Receiving: validate (parallel) then mutate (sequential); Sending: 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 or applying mutations |
| Timeout & context | Per-interceptor timeouts, chain-level context cancellation, `InvocationContext` with principal/traceId via `mcpserver.WithContextProvider` |
| 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 | Interceptors run as Go functions within the server process |
| Third-party and hybrid deployment | Handlers can call remote services; local and remote interceptors can be mixed freely. No built-in remote interceptor abstraction yet |

## Not Implemented

| Area | SEP expects | Notes |
|------|-------------|-------|
| Wildcard event matching | `type InterceptorEvent = ... \| "*/request" \| "*/response" \| "*"` | `matchesEvent` does exact match only; wildcard patterns are planned |
| Protocol methods | `interceptors/list`, `interceptor/invoke`, `interceptor/executeChain` | Requires upstream go-sdk changes to register custom JSON-RPC methods |
| Server → client interception | Client features as interceptable events: `"sampling/createMessage"`, `"elicitation/create"`, `"roots/list"` | Requires a `sendingMiddleware` installed via `Server.AddSendingMiddleware`. Outgoing requests run mutate → validate, incoming responses run validate → mutate (same `executeForSending`/`executeForReceiving` methods). Interceptors match by event name (no API changes needed for registration) |
195 changes: 195 additions & 0 deletions go/sdk/doc/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Go SDK Interceptors — Design Document

## Integration Point: Receiving Middleware

The go-sdk processes an incoming JSON-RPC message in this order:

```
Transport (SSE / stdio)
→ JSON-RPC decode
→ Params deserialization (json.RawMessage → typed struct)
→ Receiving middleware chain ← we hook in here
→ Method handler (e.g. tool handler)
→ Result returned through middleware
→ JSON-RPC encode
→ Transport
```

## Capability Declaration

During initialization, the 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
- `interceptors` — full metadata array in wire format

## Request/Response Lifecycle

When a JSON-RPC request arrives, `receivingMiddleware` in
`mcpserver/server.go` runs the following sequence:

```
0. If method == "initialize" → enrich result with capability declaration
1. Assign typed params to Invocation inv.Payload = req.GetParams()
2. Run request-phase chain (validate → mutate)
3. If aborted → return error
4. Params already modified in place — no unmarshal needed
5. Call next handler next(ctx, method, req)
6. Assign result to Invocation inv.Payload = result
7. Run response-phase chain (mutate → validate)
8. If aborted → return error
9. Result already modified in place — no unmarshal needed
10. Return result
```

The JSON-RPC method name is used directly as the event name (e.g. `"tools/call"`).

### Typed Payload — Zero JSON Operations

`Invocation.Payload` is `any`, the live Go value from the go-sdk
(e.g. `*mcp.CallToolParamsRaw`). Handlers type-assert directly, the
same pattern as gRPC-Go interceptors (`req any`). No JSON marshaling
or unmarshaling occurs in the normal path.

```go
// Validator — type-assert, inspect, return:
params, ok := inv.Payload.(*mcp.CallToolParamsRaw)
if !ok {
return nil, fmt.Errorf("unexpected payload type %T", inv.Payload)
}

// Mutator — type-assert, modify in place, return:
result, ok := inv.Payload.(*mcp.CallToolResult)
if !ok {
return nil, fmt.Errorf("unexpected payload type %T", inv.Payload)
}
result.Content[0] = &mcp.TextContent{Text: "modified"}
return &MutationResult{Modified: true}, nil
```

**Audit mode:** Audit-mode mutators receive a deep-copied payload (via
`Invocation.withCopiedPayload()`) so their in-place modifications don't
affect the real struct. The deep copy uses a JSON round-trip
(`json.Marshal` → `reflect.New` → `json.Unmarshal`). Only audit-mode
mutators pay this cost.

### Limitations

- **Params must round-trip through JSON faithfully** for audit-mode deep
copy. All go-sdk param and result types use standard `encoding/json`
tags, so this holds in practice.
- **Type assertions require knowing the concrete type.** Interceptors must
know which type to expect for a given event (e.g. `*mcp.CallToolParamsRaw`
for `tools/call` requests). The `Events` field on `Metadata` narrows
which events reach a handler, so single-event interceptors always see
the expected type.

---

## 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 untouched.

### Not Intercepted

1. **Progress notifications.** During a tool call, a handler can call
`session.NotifyProgress()`. These are JSON-RPC *notifications* sent
directly over the transport — they do not flow through `MethodHandler`
middleware. Interceptors never see them.

2. **Transport-level SSE streaming.** The Streamable HTTP transport
multiplexes multiple JSON-RPC messages over a single SSE connection.
This is connection management, not per-message streaming. Each individual
method call is still a single request → single response, which the
middleware intercepts normally.

3. **JSON-RPC notifications** (e.g. `notifications/initialized`,
`notifications/cancelled`). The go-sdk routes notifications through a
separate handler path, not through `MethodHandler` middleware.

Notification interception is not defined by the proposal.
If this becomes necessary, it would require a separate notification middleware
hook in the go-sdk.

---

## Chain Execution Model

The `chainExecutor` in `chain_executor.go` implements trust-boundary-aware
execution:

**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.

### Validator execution
- All matching validators run in parallel (goroutines).
- A validator returning `Valid: false` with `Severity: "error"` in enforced
mode (`Mode: ModeOn`) aborts the chain.
- `FailOpen: true` validators log errors and record an `ExecutionResult`
(with `Error` populated) for observability, but don't abort.

### Mutator execution
- Mutators run sequentially, ordered by `PriorityHint.Resolve(phase)`
(ascending), with alphabetical name tiebreak.
- Each mutator modifies the typed payload in place via type assertion on `inv.Payload`.
- If any mutator fails (and is not `FailOpen`), the chain aborts.
`FailOpen` mutators record an `ExecutionResult` (with `Error` populated)
and continue.
- In `ModeAudit`, the mutator runs on a deep-copied payload and its result
is recorded, but the real payload is not affected.

### Filtering
`newChainExecutor` filters the full interceptor set by:
1. `Mode != ModeOff`
2. Phase matches (or interceptor phase is `PhaseBoth`)
3. Event matches (exact match only; wildcard support is planned via `matchesEvent`)

---

## File Map

### `interceptors/` — protocol-agnostic core (zero MCP imports)

| File | Responsibility |
|------|---------------|
| `interceptor.go` | Types (Phase, Mode, InterceptorType, Priority, Severity, Compat), Metadata struct, Interceptor interface, Validator/Mutator structs and handler types |
| `invocation.go` | Invocation (with audit-mode payload cloning), InvocationContext, Principal — the input to every handler |
| `result.go` | All outcome types: ValidationResult, MutationResult, ExecutionResult, ChainResult, AbortInfo |
| `chain.go` | `Chain` public API: `NewChain`, `Add`, `ExecuteForReceiving`, `ExecuteForSending`, `IsEmpty`, `Interceptors` |
| `chain_executor.go` | `interceptorSnapshot` (atomic snapshot with lazy chain cache), `chainExecutor` struct, `newChainExecutor` (filtering + sorting), `executeForReceiving`, `executeForSending`, `timeoutResult`, `matchesPhase`, `matchesEvent` |
| `chain_validate.go` | `validatorResult` struct, `runValidators` (parallel dispatch + N=1 fast path), `executeValidator`, `recordValidation` |
| `chain_mutate.go` | `mutatorOutcome` type + constants, `runMutators` (sequential loop + audit-mode copy), `executeMutator` |

### `interceptors/mcpserver/` — MCP server integration

| File | Responsibility |
|------|---------------|
| `server.go` | `Server` wrapper, `receivingMiddleware`, `WithContextProvider`, capability declaration, `NewStreamableHTTPHandler`, `abortToJSONRPCError` |
| `events.go` | Event name constants for standard MCP methods |
51 changes: 51 additions & 0 deletions go/sdk/doc/PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Go SDK Interceptors — Performance

Analysis of per-request costs and allocation patterns.

---

## Design Rationale: Typed Payload

The interceptor chain passes Go's typed params/result objects directly
to handlers, avoiding JSON serialization entirely in the normal path.
This follows the same pattern used by gRPC-Go, where interceptors
receive the request as `any` and type-assert to the concrete type.

`Invocation.Payload` is `any, the same pointer the go-sdk already
allocated during JSON-RPC deserialization. No wrapper struct, no
intermediate copies. Mutators modify the value in place through the
pointer (no marshal/unmarshal round-trip)

---

## Per-Request Cost Model

Every intercepted request passes through the middleware in
`mcpserver/server.go`. The cost depends on whether interceptors match
the event.

### Fast Path (no matching interceptors)

When no interceptors match, the middleware cost is:

1. One atomic pointer load: `s.chain.snapshot.Load()`
2. Two `sync.Map` lookups on the chain cache: `getChain(event, PhaseRequest)` and `getChain(event, PhaseResponse)`
3. One boolean check: `ce.empty`

Zero allocations, zero JSON operations.

### Intercepted Path

With interceptors active, each active phase
incurs:

| Step | Operation | Allocations | JSON ops |
|------|-----------|-------------|----------|
| 1 | `Invocation` struct | 1 struct | 0 |
| 2 | `ChainResult` struct (with pre-allocated `Results` slice) | 1 struct + 1 slice | 0 |
| 3 | Validator execution | 0 (N=1) / goroutines (N>1) | 0 |
| 4 | Mutator execution | 0 | 0 |
| 5 | Audit-mode deep copy | 1 per audit mutator | 1 marshal + 1 unmarshal |

Zero JSON operations in the normal path. Phases with no matching
interceptors are skipped entirely.
Empty file removed go/sdk/examples/.gitkeep
Empty file.
Loading
Loading