Go SDK: Upgrade MCP SDK and remove vendored copy (#35)

<!-- Provide a brief summary of your changes -->
Update the Go interceptor SDK to use
`github.com/modelcontextprotocol/go-sdk` v1.7.0 custom method APIs and
remove the vendored Go SDK copy. This shouldn't update the behaviours,
but addding following functions.

Changed public/user-facing interfaces:
extension.Extension.Install now returns an error:

```
// before
ext.Install(mcpServer)

// after
if err := ext.Install(mcpServer); err != nil {
	return err
}
```

New helper added:

```
extension.RegisterSendingMethods(client *mcp.Client) error
```

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->

Pick up MCP Go SDK 1.7.0. This provides upstream custom method support,
so the local vendored SDK copy is no longer needed.

## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->

- `go test -v ./...`

## Breaking Changes
<!-- Will users need to update their code or configurations? -->

```go
if err := ext.Install(mcpServer); err != nil {
    return err
}
```

The Go SDK module now requires Go 1.25 via
github.com/modelcontextprotocol/go-sdk v1.7.0.

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->
LocalChain now registers interceptor custom sending methods internally.
Manual remote chain setup should call
`extension.RegisterSendingMethods(client)` before connecting the MCP
client session used with chain.AddMCPServer.

Signed-off-by: Ukjae Jeong <jeongukjae@gmail.com>
This commit is contained in:
Ukjae Jeong 2026-08-12 22:48:17 +01:00 • committed by GitHub
commit 8704137635
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
264 changed files with 231 additions and 57228 deletions

View file

@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.24", "1.25"]
go: ["1.25", "1.26"]
steps:
- name: Check out code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
@ -73,8 +73,8 @@ jobs:
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "1.24"
go-version: "1.25"
- name: Test with -race
run: |
cd go/sdk
go test -v -race ./...
go test -v -race ./...

View file

@ -79,9 +79,8 @@ jobs:
],
go: [
'Go CI / Linting',
'Go CI / Unit Tests (1.23)',
'Go CI / Unit Tests (1.24)',
'Go CI / Unit Tests (1.25)',
'Go CI / Unit Tests (1.26)',
'Go CI / Race Detection'
],
typescript: [
@ -145,4 +144,4 @@ jobs:
core.setFailed(`Required checks have not passed:\\n${failureMessage}`);
} else {
console.log(`All required checks passed:\\n${failureMessage}`);
}
}

View file

@ -3,8 +3,6 @@
Go implementation of the MCP Interceptor Extension based on
[SEP-2624](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2624).
Note: Currently the MCP SDK is vendored, in-order to add the Protocol Methods needed for interceptors.
## Quick Start
```go
@ -36,7 +34,9 @@ ext.AddInterceptor(&interceptors.Validator{
})
// Install on the server and create a chain for middleware.
ext.Install(mcpServer)
if err := ext.Install(mcpServer); err != nil {
log.Fatal(err)
}
chain, err := ext.LocalChain(ctx, mcpServer)
mcpServer.AddReceivingMiddleware(gomiddleware.Middleware(chain))

View file

@ -9,8 +9,9 @@ Status of this Go SDK implementation against the SEP-2624 interceptor proposal.
| 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 |
| Event names | Constants for common server-side MCP methods; other JSON-RPC method names can be used directly as event names |
| Protocol methods | `interceptors/list` for discovery, `interceptor/invoke` for invocation — both registered as custom JSON-RPC methods |
| MCP Go SDK integration | Uses `github.com/modelcontextprotocol/go-sdk` custom method APIs (`AddReceivingCustomMethod`, `AddSendingCustomMethod`, `CallCustomMethod`, `ParamsBase`, `ResultBase`) from v1.7.0; no vendored SDK patch is required |
| `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 |
@ -23,9 +24,9 @@ Status of this Go SDK implementation against the SEP-2624 interceptor proposal.
| 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` |
| Receiving direction (client → server) | Server-side messages routed through `AddReceivingMiddleware` are intercepted, except skipped lifecycle and interceptor methods |
| Capability declaration | Interceptor metadata injected into `initialize` response via `Capabilities.Extensions` |
| First-party (in-process) deployment | `Server.LocalChain()` creates an in-memory transport `chain.Chain`; interceptors invoked via JSON-RPC even in-process |
| First-party (in-process) deployment | `extension.Extension.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 |
@ -33,7 +34,8 @@ Status of this Go SDK implementation against the SEP-2624 interceptor proposal.
| 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 |
| Wildcard event matching | `"*"` MUST match all lifecycle events; namespace wildcards such as `"tools/*"` MAY be supported | `matchesHooks` does exact event matching only today |
| Sending-side interception | Sending flow uses `Mutate → Validate → Send` for client→server and server→client boundaries | Only the server receiving middleware integration exists. `go-sdk` v1.7.0 now provides `Client.AddSendingMiddleware` and `Server.AddSendingMiddleware`, so this is a local implementation gap rather than an upstream SDK blocker |
| Client feature events | `"sampling/createMessage"`, `"elicitation/create"`, `"roots/list"` | No client-side middleware integration yet; callers may still use raw method-name strings as events |
| Per-interceptor config source and validation | `ChainExecutionParams.Config` overrides and `configSchema` validation | `ExecutionParams.Config` is passed through to `InvokeParams.Config`, but middleware does not load config and `configSchema` is not validated |
| Remote interceptor server helpers | Convenient setup for stdio/HTTP interceptor servers | Infrastructure is ready (`chain.Chain.AddMCPServer` accepts any `*mcp.ClientSession`); no config-driven or transport-specific helper APIs yet |

View file

@ -38,9 +38,10 @@ 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
4. Registers the interceptor custom sending methods on the client
5. Calls `chain.AddMCPServer(ctx, clientSession)` which discovers
interceptors via `interceptors/list`
5. Returns the ready-to-use chain
6. 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
@ -59,22 +60,24 @@ The capability payload includes:
## Request/Response Lifecycle
When a JSON-RPC request arrives, `gomiddleware.Middleware` runs:
When a JSON-RPC request or notification reaches the server receiving
middleware, `gomiddleware.Middleware` runs:
```
0. If method is skipped (initialize, notifications/*, interceptor/*) → passthrough
0. If method is skipped → 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})
6. If the handler returns nil result (notification) → return
7. Marshal response result to json.RawMessage
8. 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
9. If aborted → return JSON-RPC error
10. Unmarshal mutated payload back into response result
11. Return result
```
### JSON-RPC Payload Model
@ -82,7 +85,8 @@ When a JSON-RPC request arrives, `gomiddleware.Middleware` runs:
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`:
modify, and (for mutators) return the updated JSON in
`MutationResult.Payload`:
```go
// Validator — unmarshal, inspect, return:
@ -118,7 +122,9 @@ avoid intercepting lifecycle events:
### Intercepted
All JSON-RPC **method calls** routed through the server's receiving middleware:
All JSON-RPC messages routed through the server's receiving middleware
except the skipped methods above. The current integration is server-side
receiving middleware only.
| Method | Event |
|--------|-------|
@ -130,8 +136,9 @@ All JSON-RPC **method calls** routed through the server's receiving middleware:
| `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).
Custom methods registered with the Go SDK's receiving custom-method API
also pass through the middleware and are intercepted using the JSON-RPC
method name as the event name.
### Not Intercepted
@ -175,9 +182,9 @@ Response-phase validators see the post-mutation payload.
### 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.
- Each mutator receives `json.RawMessage`, unmarshals, modifies, and
returns updated JSON in `MutationResult.Payload`. The chain passes the
returned 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.
@ -198,7 +205,7 @@ Response-phase validators see the post-mutation payload.
|------|---------------|
| `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) |
| `wire.go` | Interceptor JSON-RPC method names, public event constants for common server methods, wire types (ListParams, ListResult, InvokeParams, InvokeResult, InterceptorInfo) |
### `interceptors/chain/` — chain orchestrator
@ -212,8 +219,8 @@ Response-phase validators see the post-mutation payload.
| File | Responsibility |
|------|---------------|
| `server.go` | `Extension`, interceptor management, `Install`, `LocalChain`, capability declaration |
| `client.go` | `RegisterSendingMethods`, which registers interceptor custom methods on an MCP client before connecting |
| `rpc.go` | `handleList` and `handleInvoke` JSON-RPC method handlers |
| `events.go` | Event name constants for standard MCP methods |
### `interceptors/integrations/gomiddleware/` — middleware

View file

@ -8,8 +8,8 @@ Analysis of per-request costs and allocation patterns.
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).
session. For in-process interceptors, `Extension.LocalChain(ctx, server)`
creates an in-memory transport, so there is no network I/O.
Payloads are `json.RawMessage` at the protocol level. The middleware
marshals request params and response results once per phase, and
@ -37,7 +37,7 @@ With interceptors active, each active phase incurs:
| 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 |
| 4 | Validator execution | 1 goroutine per validator | 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) |

View file

@ -77,7 +77,9 @@ func main() {
// resources via interceptors/list and interceptor/invoke).
ext := extension.New()
ext.AddInterceptor(m)
ext.Install(mcpServer)
if err := ext.Install(mcpServer); err != nil {
log.Fatal(err)
}
// Create a chain connected via in-memory transport.
chain, err := ext.LocalChain(context.Background(), mcpServer)

View file

@ -74,7 +74,9 @@ func main() {
// resources via interceptors/list and interceptor/invoke).
ext := extension.New()
ext.AddInterceptor(v)
ext.Install(mcpServer)
if err := ext.Install(mcpServer); err != nil {
log.Fatal(err)
}
// Create a chain connected via in-memory transport.
chain, err := ext.LocalChain(context.Background(), mcpServer)

View file

@ -1,22 +1,22 @@
module github.com/modelcontextprotocol/ext-interceptors/go/sdk
go 1.24.0
toolchain go1.24.3
go 1.25.0
require (
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/modelcontextprotocol/go-sdk v1.7.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/google/jsonschema-go v0.4.3 // 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/segmentio/encoding v0.5.4 // 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
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,29 +1,33 @@
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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/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/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
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/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/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=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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=

View file

@ -116,12 +116,18 @@ type ExecutionResult struct {
// 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.).
// (in-memory, stdio, HTTP, etc.). The session's client must have been
// configured with the interceptor extension's sending methods before connecting.
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 {
result, err := mcp.CallCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
ctx, cs, interceptors.MethodList, nil,
)
if err != nil {
return err
}
if result == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
@ -142,7 +148,7 @@ func (c *Chain) AddMCPServer(ctx context.Context, cs *mcp.ClientSession) error {
// - 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
// - Call interceptor/invoke via the MCP SDK custom-method API 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) {
@ -443,9 +449,13 @@ func (c *Chain) callInvoke(
}
next := func(ctx context.Context, p *interceptors.InvokeParams) (interceptors.InvokeResult, error) {
var result interceptors.InvokeResult
err := entry.Server.CallCustom(ctx, interceptors.MethodInvoke, p, &result)
return result, err
result, err := mcp.CallCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
ctx, entry.Server, interceptors.MethodInvoke, p,
)
if result == nil {
return interceptors.InvokeResult{}, err
}
return *result, err
}
if c.handler != nil {

View file

@ -17,6 +17,7 @@ import (
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/chain"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors/extension"
)
// setupChainWithInterceptors creates an MCP server with the given
@ -34,7 +35,7 @@ func setupChainWithOpts(t *testing.T, opts []chain.ChainOption, is ...intercepto
Version: "0.1.0",
}, nil)
registerInterceptorMethods(mcpServer, is)
registerInterceptorMethods(t, mcpServer, is)
serverTransport, clientTransport := mcp.NewInMemoryTransports()
@ -46,6 +47,7 @@ func setupChainWithOpts(t *testing.T, opts []chain.ChainOption, is ...intercepto
Name: "chain-test-client",
Version: "0.1.0",
}, nil)
require.NoError(t, extension.RegisterSendingMethods(client))
cs, err := client.Connect(context.Background(), clientTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { cs.Close() })
@ -60,12 +62,13 @@ func setupChainWithOpts(t *testing.T, opts []chain.ChainOption, is ...intercepto
// 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) {
mcp.AddReceivingCustomMethod(server, interceptors.MethodList,
func(_ context.Context, req *mcp.ServerRequest[*interceptors.ListParams]) (*interceptors.ListResult, error) {
func registerInterceptorMethods(t *testing.T, server *mcp.Server, is []interceptors.Interceptor) {
t.Helper()
require.NoError(t, mcp.AddReceivingCustomMethod(server, interceptors.MethodList,
func(_ context.Context, _ *mcp.ServerSession, params *interceptors.ListParams) (*interceptors.ListResult, error) {
var event string
if req.Params != nil {
event = req.Params.Event
if params != nil {
event = params.Event
}
infos := make([]interceptors.InterceptorInfo, 0, len(is))
for _, i := range is {
@ -87,14 +90,13 @@ func registerInterceptorMethods(server *mcp.Server, is []interceptors.Intercepto
}
return &interceptors.ListResult{Interceptors: infos}, nil
},
)
))
mcp.AddReceivingCustomMethod(server, interceptors.MethodInvoke,
func(ctx context.Context, req *mcp.ServerRequest[*interceptors.InvokeParams]) (*interceptors.InvokeResult, error) {
if req.Params == nil {
require.NoError(t, mcp.AddReceivingCustomMethod(server, interceptors.MethodInvoke,
func(ctx context.Context, _ *mcp.ServerSession, params *interceptors.InvokeParams) (*interceptors.InvokeResult, error) {
if params == nil {
return nil, fmt.Errorf("params required")
}
params := req.Params
var target interceptors.Interceptor
for _, i := range is {
if i.GetMetadata().Name == params.Name {
@ -137,7 +139,7 @@ func registerInterceptorMethods(server *mcp.Server, is []interceptors.Intercepto
}
return result, nil
},
)
))
}
func TestChain_ExecutionHandler(t *testing.T) {

View file

@ -26,6 +26,14 @@
// A [Chain] is created with [NewChain] and populated by adding MCP
// servers via [Chain.AddMCPServer]:
//
// client := mcp.NewClient(clientImpl, nil)
// if err := extension.RegisterSendingMethods(client); err != nil {
// return err
// }
// clientSession, err := client.Connect(ctx, transport, nil)
// if err != nil {
// return err
// }
// chain := chain.NewChain()
// chain.AddMCPServer(ctx, clientSession)
// mcpServer.AddReceivingMiddleware(

View file

@ -22,7 +22,9 @@
// ext := extension.New()
// ext.AddInterceptor(myValidator)
// ext.AddInterceptor(myMutator)
// ext.Install(mcpServer)
// if err := ext.Install(mcpServer); err != nil {
// return err
// }
//
// chain, err := ext.LocalChain(ctx, mcpServer)
// mcpServer.AddReceivingMiddleware(
@ -64,8 +66,8 @@
//
// 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
// as [json.RawMessage], unmarshals it, modifies it, and returns the
// updated JSON in [MutationResult.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.

View file

@ -0,0 +1,24 @@
// 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 extension
import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/ext-interceptors/go/sdk/interceptors"
)
// RegisterSendingMethods registers the interceptor custom RPC methods on a
// client before it connects to interceptor-capable servers.
func RegisterSendingMethods(client *mcp.Client) error {
if err := mcp.AddSendingCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
client, interceptors.MethodList,
); err != nil {
return err
}
return mcp.AddSendingCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
client, interceptors.MethodInvoke,
)
}

View file

@ -16,7 +16,9 @@
// ext := extension.New(extension.WithLogger(logger))
// ext.AddInterceptor(myValidator)
// ext.AddInterceptor(myMutator)
// ext.Install(mcpServer)
// if err := ext.Install(mcpServer); err != nil {
// return err
// }
//
// At this point any connected MCP client can call interceptors/list
// and interceptor/invoke.

View file

@ -18,10 +18,10 @@ import (
// handleList implements the "interceptors/list" JSON-RPC method.
// It returns all registered interceptors, optionally filtered by event.
func (e *Extension) handleList(_ context.Context, req *mcp.ServerRequest[*interceptors.ListParams]) (*interceptors.ListResult, error) {
func (e *Extension) handleList(_ context.Context, _ *mcp.ServerSession, params *interceptors.ListParams) (*interceptors.ListResult, error) {
var event string
if req.Params != nil {
event = req.Params.Event
if params != nil {
event = params.Event
}
all := e.getInterceptors()
@ -38,14 +38,13 @@ func (e *Extension) handleList(_ context.Context, req *mcp.ServerRequest[*interc
// handleInvoke implements the "interceptor/invoke" JSON-RPC method.
// It invokes a single interceptor by name and returns its result.
func (e *Extension) handleInvoke(ctx context.Context, req *mcp.ServerRequest[*interceptors.InvokeParams]) (*interceptors.InvokeResult, error) {
if req.Params == nil || req.Params.Name == "" {
func (e *Extension) handleInvoke(ctx context.Context, _ *mcp.ServerSession, params *interceptors.InvokeParams) (*interceptors.InvokeResult, error) {
if params == nil || params.Name == "" {
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: "name is required",
}
}
params := req.Params
// Look up the interceptor by name.
i := e.findByName(params.Name)

View file

@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -22,8 +23,9 @@ func TestListInterceptors(t *testing.T) {
blockToolValidator("dangerous"),
)
var result interceptors.ListResult
err := cs.CallCustom(context.Background(), interceptors.MethodList, nil, &result)
result, err := mcp.CallCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
context.Background(), cs, interceptors.MethodList, nil,
)
require.NoError(t, err)
assert.Len(t, result.Interceptors, 2)
@ -56,25 +58,28 @@ func TestListWithEventFilter(t *testing.T) {
cs := setupRPCServer(t, toolsValidator, promptsValidator)
// Filter for tools/call — should only return tools-v
var result interceptors.ListResult
err := cs.CallCustom(context.Background(), interceptors.MethodList,
&interceptors.ListParams{Event: interceptors.EventToolsCall}, &result)
result, err := mcp.CallCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
context.Background(), cs, interceptors.MethodList,
&interceptors.ListParams{Event: interceptors.EventToolsCall},
)
require.NoError(t, err)
assert.Len(t, result.Interceptors, 1)
assert.Equal(t, "tools-v", result.Interceptors[0].Name)
// Filter for prompts/get — should only return prompts-v
var result2 interceptors.ListResult
err = cs.CallCustom(context.Background(), interceptors.MethodList,
&interceptors.ListParams{Event: interceptors.EventPromptsGet}, &result2)
result2, err := mcp.CallCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
context.Background(), cs, interceptors.MethodList,
&interceptors.ListParams{Event: interceptors.EventPromptsGet},
)
require.NoError(t, err)
assert.Len(t, result2.Interceptors, 1)
assert.Equal(t, "prompts-v", result2.Interceptors[0].Name)
// Filter for non-existent event — empty
var result3 interceptors.ListResult
err = cs.CallCustom(context.Background(), interceptors.MethodList,
&interceptors.ListParams{Event: "nonexistent/method"}, &result3)
result3, err := mcp.CallCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
context.Background(), cs, interceptors.MethodList,
&interceptors.ListParams{Event: "nonexistent/method"},
)
require.NoError(t, err)
assert.Empty(t, result3.Interceptors)
}
@ -87,14 +92,15 @@ func TestInvokeValidator(t *testing.T) {
cs := setupRPCServer(t, allowAllValidator("v1"))
payload, _ := json.Marshal(map[string]any{"name": "echo", "arguments": map[string]any{"text": "hello"}})
var result interceptors.InvokeResult
err := cs.CallCustom(context.Background(), interceptors.MethodInvoke,
result, err := mcp.CallCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
context.Background(), cs, interceptors.MethodInvoke,
&interceptors.InvokeParams{
Name: "v1",
Event: interceptors.EventToolsCall,
Phase: interceptors.PhaseRequest,
Payload: payload,
}, &result)
},
)
require.NoError(t, err)
assert.Equal(t, "v1", result.Interceptor)
@ -131,14 +137,15 @@ func TestInvokeValidatorRejects(t *testing.T) {
cs := setupRPCServer(t, rejectAll)
payload, _ := json.Marshal(map[string]any{"name": "echo"})
var result interceptors.InvokeResult
err := cs.CallCustom(context.Background(), interceptors.MethodInvoke,
result, err := mcp.CallCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
context.Background(), cs, interceptors.MethodInvoke,
&interceptors.InvokeParams{
Name: "reject-all",
Event: interceptors.EventToolsCall,
Phase: interceptors.PhaseRequest,
Payload: payload,
}, &result)
},
)
require.NoError(t, err)
assert.Equal(t, "reject-all", result.Interceptor)
@ -182,14 +189,15 @@ func TestInvokeMutator(t *testing.T) {
cs := setupRPCServer(t, mutator)
payload, _ := json.Marshal(map[string]any{"name": "echo"})
var result interceptors.InvokeResult
err := cs.CallCustom(context.Background(), interceptors.MethodInvoke,
result, err := mcp.CallCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
context.Background(), cs, interceptors.MethodInvoke,
&interceptors.InvokeParams{
Name: "add-field",
Event: interceptors.EventToolsCall,
Phase: interceptors.PhaseRequest,
Payload: payload,
}, &result)
},
)
require.NoError(t, err)
assert.Equal(t, "add-field", result.Interceptor)
@ -212,14 +220,15 @@ func TestInvokeUnknownName(t *testing.T) {
cs := setupRPCServer(t, allowAllValidator("v1"))
payload, _ := json.Marshal(map[string]any{})
var result interceptors.InvokeResult
err := cs.CallCustom(context.Background(), interceptors.MethodInvoke,
_, err := mcp.CallCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
context.Background(), cs, interceptors.MethodInvoke,
&interceptors.InvokeParams{
Name: "nonexistent",
Event: interceptors.EventToolsCall,
Phase: interceptors.PhaseRequest,
Payload: payload,
}, &result)
},
)
require.Error(t, err)
assert.Contains(t, err.Error(), "nonexistent")
@ -250,15 +259,16 @@ func TestInvokeTimeout(t *testing.T) {
cs := setupRPCServer(t, slowValidator)
payload, _ := json.Marshal(map[string]any{})
var result interceptors.InvokeResult
err := cs.CallCustom(context.Background(), interceptors.MethodInvoke,
_, err := mcp.CallCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
context.Background(), cs, interceptors.MethodInvoke,
&interceptors.InvokeParams{
Name: "slow-v",
Event: interceptors.EventToolsCall,
Phase: interceptors.PhaseRequest,
Payload: payload,
TimeoutMs: 50,
}, &result)
},
)
require.Error(t, err)
}

View file

@ -6,6 +6,7 @@ package extension
import (
"context"
"fmt"
"log/slog"
"sync"
@ -30,9 +31,9 @@ func WithLogger(l *slog.Logger) Option {
}
// Extension manages interceptors and can install them on one or more
// *mcp.Server instances. Interceptors are registered as first-class
// MCP resources discoverable via the "interceptors/list" JSON-RPC
// method and invocable via "interceptor/invoke".
// *mcp.Server instances. Interceptors are exposed as custom MCP methods:
// discoverable via the "interceptors/list" JSON-RPC method and invocable
// via "interceptor/invoke".
//
// Extension is a pure interceptor container — it does not hold a
// reference to any particular server. Call [Extension.Install] to
@ -59,11 +60,16 @@ func New(opts ...Option) *Extension {
// JSON-RPC methods and installs a receiving middleware to enrich the
// initialize response with interceptor capabilities on the given
// server. It can be called on multiple servers.
func (e *Extension) Install(server *mcp.Server) {
func (e *Extension) Install(server *mcp.Server) error {
// Register JSON-RPC methods for interceptor discovery and invocation.
mcp.AddReceivingCustomMethod(server, interceptors.MethodList, e.handleList)
mcp.AddReceivingCustomMethod(server, interceptors.MethodInvoke, e.handleInvoke)
if err := mcp.AddReceivingCustomMethod(server, interceptors.MethodList, e.handleList); err != nil {
return fmt.Errorf("interceptors: register %q: %w", interceptors.MethodList, err)
}
if err := mcp.AddReceivingCustomMethod(server, interceptors.MethodInvoke, e.handleInvoke); err != nil {
return fmt.Errorf("interceptors: register %q: %w", interceptors.MethodInvoke, err)
}
server.AddReceivingMiddleware(e.initMiddleware())
return nil
}
// AddInterceptor registers an interceptor. It panics if the interceptor
@ -128,6 +134,10 @@ func (e *Extension) LocalChain(ctx context.Context, server *mcp.Server) (*chain.
Name: "interceptor-chain-client",
Version: "internal",
}, nil)
if err := RegisterSendingMethods(client); err != nil {
ss.Close()
return nil, err
}
cs, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
ss.Close()

View file

@ -637,20 +637,23 @@ func TestCustomMethodsFlowThroughMiddleware(t *testing.T) {
require.NoError(t, err)
assert.Greater(t, count.Swap(0), int64(0), "middleware must fire for tools/call")
var listResult interceptors.ListResult
err = cs.CallCustom(context.Background(), interceptors.MethodList, nil, &listResult)
listResult, err := mcp.CallCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
context.Background(), cs, interceptors.MethodList, nil,
)
require.NoError(t, err)
assert.Len(t, listResult.Interceptors, 1)
assert.Greater(t, count.Swap(0), int64(0), "middleware must fire for interceptors/list")
payload, _ := json.Marshal(map[string]any{"name": "echo", "arguments": map[string]any{}})
var invokeResult interceptors.InvokeResult
err = cs.CallCustom(context.Background(), interceptors.MethodInvoke, &interceptors.InvokeParams{
Name: "v1",
Event: interceptors.EventToolsCall,
Phase: interceptors.PhaseRequest,
Payload: payload,
}, &invokeResult)
_, err = mcp.CallCustomMethod[*interceptors.InvokeParams, *interceptors.InvokeResult](
context.Background(), cs, interceptors.MethodInvoke,
&interceptors.InvokeParams{
Name: "v1",
Event: interceptors.EventToolsCall,
Phase: interceptors.PhaseRequest,
Payload: payload,
},
)
require.NoError(t, err)
assert.Greater(t, count.Swap(0), int64(0), "middleware must fire for interceptor/invoke")
}
@ -676,8 +679,9 @@ func TestMiddlewareCanRejectCustomMethods(t *testing.T) {
mcpServer.AddReceivingMiddleware(blockingMiddleware)
cs := connectHTTPClient(t, mcpServer)
var listResult interceptors.ListResult
err := cs.CallCustom(context.Background(), interceptors.MethodList, nil, &listResult)
_, err := mcp.CallCustomMethod[*interceptors.ListParams, *interceptors.ListResult](
context.Background(), cs, interceptors.MethodList, nil,
)
assert.Error(t, err, "blockingMiddleware should reject interceptors/list")
assert.True(t, rejected.Load())
}

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