Documentation
¶
Overview ¶
Package proxy forwards MCP client requests to an upstream Remote MCP Server and relays its responses back to the client. It implements the minimum surface required by the Model Context Protocol's Streamable HTTP transport (DELETE, GET, and POST on a single endpoint path) while exposing extension points for request/response inspection via interceptors.
The proxy is intentionally transport-focused: callers are responsible for authenticating the client, loading the RemoteMcpServer configuration, and decrypting any secret header values before handing the resolved data to the proxy.
Index ¶
- Constants
- Variables
- type ConfiguredHeader
- type Metrics
- type Proxy
- type RejectError
- type RemoteMessage
- type RemoteMessageInterceptor
- type ToolsCallRequest
- type ToolsCallRequestInterceptor
- type ToolsCallResponse
- type ToolsCallResponseInterceptor
- type ToolsListRequest
- type ToolsListRequestInterceptor
- type ToolsListResponse
- type ToolsListResponseInterceptor
- type UserRequest
- type UserRequestInterceptor
Constants ¶
const ( MeterRequests = "gram.remote_mcp.proxy.requests" MeterRequestDuration = "gram.remote_mcp.proxy.request.duration" MeterResponseBytes = "gram.remote_mcp.proxy.response.bytes" )
const ( // DefaultNonStreamingTimeout bounds the connect+headers phase for every // upstream request, plus the body read for non-streaming responses. The // MCP spec only mandates that implementations establish timeouts; it // does not prescribe a value, so this matches the 60s default used by // common MCP SDK implementations. // // For streaming responses (text/event-stream), this only bounds the // connect+headers phase — once headers are received, per-event idle // bounds via [DefaultStreamingTimeout] take over and the stream itself // is unbounded so long as upstream stays active. DefaultNonStreamingTimeout = 60 * time.Second // DefaultStreamingTimeout is the per-event idle bound applied to // streaming response bodies. The clock resets on every successful Read // from the upstream body, so a stream that's actively producing events // stays alive indefinitely; only inactivity longer than this duration // terminates the stream. Activity here is byte-level, so SSE keepalive // comments (`: keepalive\n`) reset the clock too — operators can keep // streams alive across long tool-call wait periods by sending // keepalives. DefaultStreamingTimeout = 60 * time.Second // DefaultMaxBufferedBodyBytes bounds the size of a JSON body that is // fully read into memory before parsing (via [io.ReadAll]) on both the // inbound user request and the upstream response. It exists to prevent // a misbehaving peer from sending arbitrarily large JSON bodies that // would exhaust server memory during parse. // // Streamed responses (Content-Type: text/event-stream) are not subject // to this cap — their bytes flow through bounded read/write buffers and // never materialize in a single allocation. Stream duration is bounded // by the request context deadline, not by this value. // // Set generously — large tool results (long-form text, base64-encoded // payloads) legitimately exceed a few MB — but bounded to keep parse // allocations predictable. Overridden per-proxy via // [Proxy.MaxBufferedBodyBytes]. DefaultMaxBufferedBodyBytes int64 = 50 * 1024 * 1024 )
const ( RejectCodeParseError = -32700 RejectCodeInvalidRequest = -32600 RejectCodeMethodNotFound = -32601 RejectCodeInvalidParams = -32602 RejectCodeInternalError = -32603 // RejectCodeServerError is the default code for proxy-imposed policy // rejections that do not map cleanly to a spec-defined code (e.g. // "blocked by tool-usage policy"). It sits in the -32000..-32099 // implementation-defined band reserved by JSON-RPC 2.0. RejectCodeServerError = -32000 )
JSON-RPC 2.0 error codes used by the proxy when synthesizing rejection responses. The negative range follows the spec: -32700..-32600 are reserved (parse error, invalid request, etc.); -32000..-32099 is the "server error" band for implementation-defined codes. Mirrors the codes used by the existing /mcp endpoint so observers see consistent codes across the two surfaces.
const ( // McpSessionIDHeader is the session header defined by the MCP Streamable // HTTP transport. McpSessionIDHeader = "Mcp-Session-Id" )
Variables ¶
var ErrBodyTooLarge = errors.New("body exceeded max size")
ErrBodyTooLarge is returned when a buffered JSON body (either the inbound user request or the upstream response) exceeds [Proxy.MaxBufferedBodyBytes] during parse. It signals a parse-time allocation guard trip, not a stream truncation — streamed responses are not subject to this cap.
Functions ¶
This section is empty.
Types ¶
type ConfiguredHeader ¶
type ConfiguredHeader struct {
// IsRequired, when true, causes the proxy to reject the request with a
// bad-request error if the header cannot be resolved to a non-empty value.
IsRequired bool
// Name is the HTTP header name to send to the remote MCP server.
Name string
// StaticValue holds a fixed value (already decrypted if originally a
// secret header). Leave empty when ValueFromRequestHeader is set.
StaticValue string
// ValueFromRequestHeader names a header on the inbound user request whose
// value should be forwarded. Leave empty when StaticValue is set.
ValueFromRequestHeader string
}
ConfiguredHeader describes how a single outgoing header sent to the remote MCP server is populated. Exactly one of StaticValue or ValueFromRequestHeader is set.
func (ConfiguredHeader) Resolve ¶
func (h ConfiguredHeader) Resolve(userReq *http.Request) (string, error)
Resolve returns the value to send upstream for this header, either pulled from the user request when ValueFromRequestHeader is set or taken from StaticValue. Returns an error when IsRequired is true but no non-empty value can be produced.
type Metrics ¶
type Metrics struct {
// contains filtered or unexported fields
}
Metrics owns the counters and histograms recorded for each proxied request. A nil *Metrics is valid — Record becomes a no-op — so tests and callers that do not care about metrics can pass nil.
func NewMetrics ¶
NewMetrics constructs the counter and histograms served by the proxy. Errors from the meter are logged and individual instruments are left nil; Record handles nil instruments so partial construction still produces usable metrics.
func (*Metrics) Record ¶
func (m *Metrics) Record(ctx context.Context, serverID string, method string, upstreamStatus int, responseBytes int64, duration time.Duration)
Record emits one sample per instrument for the proxied request. upstreamStatus is 0 when the upstream HTTP call never produced a status (timeout, DNS, blocked IP, etc.); the status class label collapses these into "error" so dashboards can alert on them without per-error-kind cardinality.
Convention: the status_class label reflects the upstream's HTTP status, not the user-facing outcome. When a response-side interceptor rejects an upstream 200 (e.g. the upstream returned data but we substituted a JSON-RPC error envelope for the user), this metric still reports "2xx" because the metric measures upstream health. User-facing outcomes are available via span status (set to error on rejection) and the gram.remote_mcp.proxy.requests counter dimensioned by error class.
type Proxy ¶
type Proxy struct {
// GuardianPolicy is used to build a fresh, non-pooled HTTP client per
// upstream request. Pooling is inappropriate here because each Proxy
// instance handles a single upstream host and discards the connection
// when the request finishes; a pooled transport would accumulate idle
// connections across distinct Remote MCP Servers without ever reusing
// them.
GuardianPolicy *guardian.Policy
Logger *slog.Logger
Tracer trace.Tracer
// NonStreamingTimeout bounds the connect+headers phase for every
// upstream request, plus the body read for non-streaming responses.
// For streaming (text/event-stream) responses this only bounds the
// connect+headers phase; per-event idle bounds via StreamingTimeout
// take over once headers are received. Callers must set an explicit
// value; use [DefaultNonStreamingTimeout] for the package default.
NonStreamingTimeout time.Duration
// StreamingTimeout is the per-event idle bound applied to streaming
// response bodies. The clock resets on every successful Read from
// upstream, so an actively producing stream stays alive indefinitely
// — only inactivity longer than this duration terminates the stream.
// Callers must set an explicit value; use [DefaultStreamingTimeout]
// for the package default.
StreamingTimeout time.Duration
// Metrics records per-request counters and histograms. Nil is safe and
// disables metrics recording; tests that do not care about metrics pass
// nil here.
Metrics *Metrics
// MaxBufferedBodyBytes bounds the size of any JSON body that is fully
// read into memory before parsing — applied to both the inbound user
// request and the upstream response. Streamed responses are not subject
// to this cap; see [DefaultMaxBufferedBodyBytes] for the rationale.
// Callers must set an explicit value; use [DefaultMaxBufferedBodyBytes]
// for the package default.
MaxBufferedBodyBytes int64
// ServerID is the Remote MCP Server UUID. When set, it is attached to
// every span emitted by the proxy so traces can be correlated across the
// HTTP lifecycle and the proxy forward.
ServerID string
// RemoteURL is the upstream endpoint all requests are forwarded to.
RemoteURL string
// Headers are applied on top of any forwarded client headers when
// constructing the upstream request.
Headers []ConfiguredHeader
// AuthorizationOverride is the Bearer token to set on the outgoing
// Authorization header. The caller's incoming Authorization is
// always dropped (Gram-issued credentials — API keys, OAuth tokens,
// chat-session JWTs — are not meaningful upstream); when this field
// is non-empty the proxy emits "Authorization: Bearer <override>"
// instead. Use it for two flows:
//
// - External OAuth: forward the caller's Bearer verbatim by
// setting this to the caller's own token (the upstream MCP
// server is the AS).
// - OAuth-proxy token swap: set this to a stored upstream
// credential resolved from the caller's Gram-issued OAuth
// token.
//
// Leave empty (default) to send no Authorization upstream.
AuthorizationOverride string
UserRequestInterceptors []UserRequestInterceptor
// RemoteMessageInterceptors run for each JSON-RPC message arriving
// from the remote MCP server: once per application/json POST response,
// and once per parseable SSE event in a streamed response. Returning
// a non-nil error blocks the message from being relayed to the user;
// see [RemoteMessageInterceptor] for transport-specific rejection
// semantics.
RemoteMessageInterceptors []RemoteMessageInterceptor
// ToolsCallRequestInterceptors run for inbound "tools/call" JSON-RPC
// requests only, after the generic UserRequestInterceptors chain has
// completed. Non-tools/call requests skip this loop entirely.
ToolsCallRequestInterceptors []ToolsCallRequestInterceptor
// ToolsCallResponseInterceptors run for "tools/call" JSON-RPC responses
// only, after the generic RemoteMessageInterceptors chain has
// completed. Dispatches from either the JSON response path or — for
// SSE responses — when a terminal response event matching the
// originating request ID is seen. Responses to non-tools/call requests
// skip this loop entirely.
ToolsCallResponseInterceptors []ToolsCallResponseInterceptor
// ToolsListRequestInterceptors run for inbound "tools/list" JSON-RPC
// requests only, after the generic UserRequestInterceptors chain has
// completed. Non-tools/list requests skip this loop entirely.
ToolsListRequestInterceptors []ToolsListRequestInterceptor
// ToolsListResponseInterceptors run for "tools/list" JSON-RPC responses
// only, after the generic RemoteMessageInterceptors chain has
// completed. Dispatches from either the JSON response path or — for
// SSE responses — when a terminal response event matching the
// originating request ID is seen. Responses to non-tools/list requests
// skip this loop entirely.
ToolsListResponseInterceptors []ToolsListResponseInterceptor
}
Proxy is a one-request handler that forwards inbound MCP client requests to a configured Remote MCP Server. A fresh value is expected per inbound request so the SessionID and interceptor state stay tied to a single client exchange.
func (*Proxy) Delete ¶
Delete forwards an inbound DELETE to the remote MCP server. In MCP's Streamable HTTP transport, DELETE is used by clients to explicitly terminate a session identified by Mcp-Session-Id (see spec § Session Management).
func (*Proxy) Get ¶
Get forwards an inbound GET to the remote MCP server. In MCP's Streamable HTTP transport, GET is used by clients to open a Server-Sent Events stream so the server can push requests and notifications unprompted (see spec § Listening for Messages from the Server). The response body is streamed through with a flush after each chunk so SSE events reach the client without being buffered to EOF.
Per the spec, upstream MUST respond with either Content-Type: text/event-stream or HTTP 405 Method Not Allowed. The text/event-stream path goes through [Proxy.relaySSEStream] for per-event interceptor dispatch; any other response (the spec'd 405, or non-conformant upstream behavior) is relayed verbatim via [writeResponse] so the user's MCP runtime sees what upstream actually said.
func (*Proxy) Post ¶
Post forwards an inbound POST to the remote MCP server, running any configured interceptors before the forward and after the response returns. POST is the primary MCP method — every JSON-RPC message sent by the client is a POST to the MCP endpoint (see spec § Sending Messages to the Server).
type RejectError ¶
type RejectError struct {
// Code is the JSON-RPC error code carried back to the user. Use one of
// the RejectCode* constants above when applicable.
Code int
// Message is the human-readable summary of the rejection. Surfaces in
// the JSON-RPC error response's "message" field, so treat it as
// user-facing. Interceptor authors should avoid putting internal
// details (DB error strings, stack traces, secret-derived data) here;
// log those via the interceptor's logger instead and pass a sanitized
// summary as Message.
Message string
// Data is an optional structured payload. Surfaces in the JSON-RPC
// error response's "data" field. Must be JSON-marshalable. Same
// user-facing constraint as Message — sanitize before populating.
Data any
}
RejectError is the typed rejection shape an interceptor can return when it wants the proxy to synthesize a JSON-RPC error response (or, on the SSE path, a JSON-RPC error event) the user's MCP runtime can correlate and surface cleanly. Returning a non-RejectError plain error from an interceptor still rejects the message; the proxy maps it through RejectErrorFromCause using a default mapping.
func RejectErrorFromCause ¶
func RejectErrorFromCause(err error) *RejectError
RejectErrorFromCause coerces an arbitrary error into a *RejectError so the proxy can always synthesize a spec-shaped rejection event when an interceptor returns an error. Walks the error chain via errors.As so a typed *RejectError still surfaces correctly even when the proxy's run* helpers wrap it with oops.E during invocation logging — the mapping is:
- A *RejectError anywhere in the chain is returned as-is. This is the common path for interceptors that opt into typed rejection: the interceptor's RejectError survives the run-helper's oops.E wrap and its Code/Message/Data flow through to the JSON-RPC envelope.
- An oops.ShareableError is mapped to a JSON-RPC code by Gram's domain-error class, mirroring the table used by the /mcp endpoint's own error-shape conversion.
- Anything else falls back to RejectCodeInternalError with a generic message — interceptors that want a richer rejection should return *RejectError directly.
func (*RejectError) Error ¶
func (e *RejectError) Error() string
Error implements [error]. The string form is intended for logs, not for user surfaces — the user-facing string is RejectError.Message.
type RemoteMessage ¶
type RemoteMessage struct {
// UserHTTPRequest is the inbound user request that triggered this
// message: the POST whose response carried the message, or the GET
// that opened a server-initiated stream. Available so interceptors
// can correlate messages back to their initiating request.
UserHTTPRequest *http.Request
// RemoteHTTPRequest is the outbound HTTP request the proxy built and
// sent to the remote MCP server: the URL the proxy resolved to, the
// method, and the headers the proxy applied (configured static and
// secret headers, plus any forwarded user headers — minus the Gram
// Authorization header, which is intentionally stripped). Available
// so interceptors can inspect exactly what was sent on behalf of the
// user.
//
// Prefer this over [http.Response.Request] on RemoteHTTPResponse:
//
// - Redirects: the proxy uses the stdlib default redirect policy.
// If the remote responds with a 3xx, RemoteHTTPResponse.Request
// points to the LAST hop in the redirect chain, with potentially
// a different URL and a redirect-stripped header set.
// RemoteHTTPRequest is always the original outbound request the
// proxy built, regardless of redirects.
// - Transport mutation: [http.Transport] (and any wrapping
// transports such as otelhttp) may add or rewrite headers
// (Connection, Accept-Encoding, tracing headers) on the wire
// copy. RemoteHTTPRequest reflects the proxy's intent before
// transport-level mutation.
// - Body: per the stdlib contract, RemoteHTTPResponse.Request.Body
// is nil — the body has already been consumed.
//
// Interceptors must not read this request's body — it has already
// been streamed upstream and the underlying reader is exhausted.
RemoteHTTPRequest *http.Request
// RemoteHTTPResponse is the upstream HTTP response. Available for
// header inspection. Interceptors must not read its body — the proxy
// has already consumed the byte stream and (for SSE) is mid-relay.
RemoteHTTPResponse *http.Response
// Message is the decoded JSON-RPC message — typically *jsonrpc.Request
// for server-initiated requests, *jsonrpc.Response for responses (which
// includes the terminal tools/call response in an SSE stream), or a
// notification (a *jsonrpc.Request without an ID).
Message jsonrpc.Message
}
RemoteMessage captures a single JSON-RPC message arriving from the remote MCP server, regardless of transport framing. Instances are constructed by the proxy and passed to each RemoteMessageInterceptor. For application/json POST responses, exactly one RemoteMessage is built per request. For text/event-stream responses (POST progress streams or GET streams), one RemoteMessage is built per parseable SSE event whose data payload decodes as JSON-RPC.
type RemoteMessageInterceptor ¶
type RemoteMessageInterceptor interface {
// InterceptRemoteMessage is called once per JSON-RPC message from the
// remote. Implementations may inspect msg and should return a non-nil
// error to block the message from being relayed. The msg pointer is
// freshly allocated per call and must not be retained past the call's
// return.
InterceptRemoteMessage(ctx context.Context, msg *RemoteMessage) error
// Name returns a stable identifier for this interceptor, used for
// tracing span attributes and log correlation.
Name() string
}
RemoteMessageInterceptor runs for each JSON-RPC message arriving from the remote MCP server, regardless of transport framing. It fires once per application/json POST response and once per parseable Server-Sent Event in a streamed response.
The contract is inspection and rejection: implementations may observe msg and return a non-nil error to block the message from being relayed to the user. Payload mutation is not yet supported — changes to msg.Message are silent no-ops and the proxy forwards the original bytes. Typed setters for payload modification will be introduced when modification becomes a requirement.
Rejection produces a spec-aligned JSON-RPC error envelope back to the user. The exact wire form depends on the message shape and transport:
On an application/json POST response with a request id, the interceptor's rejection becomes the response — HTTP 200 carrying a JSON-RPC error response with the originating id.
On a text/event-stream response (POST progress stream or GET stream), the rejected event is replaced inline with a spec-aligned substitute: responses and server-initiated requests become JSON-RPC error responses with the same id; notifications become "notifications/message" log notifications at level "error".
Returning a *RejectError lets the interceptor pick the JSON-RPC error code, message, and data; returning a plain error falls back to a generic mapping (see RejectErrorFromCause).
Interceptors run synchronously per message. Slow interceptors delay streaming throughput on SSE responses — keep the body cheap.
type ToolsCallRequest ¶
type ToolsCallRequest struct {
// Params is the decoded tools/call params. Arguments is retained as a
// [json.RawMessage] so implementations can Unmarshal into tool-specific
// argument schemas without a double-decode round-trip.
Params *mcp.CallToolParamsRaw
// UserRequest is the underlying request. Other interceptors in the generic
// chain may already have observed it. Callers should prefer Params for
// tools/call-specific data; UserRequest is exposed for RPC-level needs (JSON-RPC
// ID, raw messages) and for forwarding control via the underlying HTTP
// request.
UserRequest *UserRequest
}
ToolsCallRequest is a "tools/call"-specific view over a UserRequest. Instances are constructed by the proxy and passed to each ToolsCallRequestInterceptor after the generic UserRequestInterceptor chain has run.
type ToolsCallRequestInterceptor ¶
type ToolsCallRequestInterceptor interface {
// InterceptToolsCallRequest is called with the parsed tools/call request.
// Implementations may inspect call and should return a non-nil error to
// reject the tool invocation; the interceptor's error is surfaced to the
// user and the request is not forwarded to the remote server.
InterceptToolsCallRequest(ctx context.Context, call *ToolsCallRequest) error
// Name returns a stable identifier for this interceptor, used for tracing
// span attributes and log correlation.
Name() string
}
ToolsCallRequestInterceptor runs for each inbound "tools/call" JSON-RPC request after the generic UserRequestInterceptor chain has completed and before the request is forwarded to the remote MCP server.
The current contract is inspection and rejection: implementations may observe call and return a non-nil error to reject the tool invocation. Rejection produces a JSON-RPC error envelope back to the user with the originating tools/call request id. Returning a *RejectError lets the interceptor pick the JSON-RPC error code, message, and data; returning a plain error falls back to a generic mapping (see RejectErrorFromCause).
Payload mutation is not yet supported — changes to call.User or call.Params are silent no-ops and the request body is forwarded verbatim. Typed setters for payload modification will be introduced when modification becomes a requirement.
Non-"tools/call" requests are not routed to this interface; implement UserRequestInterceptor for RPC-agnostic hooks.
type ToolsCallResponse ¶
type ToolsCallResponse struct {
// Error is the JSON-RPC protocol error when upstream returned an error
// response (e.g. "tool not found", "method not found"). Mutually
// exclusive with Result.
Error *jsonrpc.Error
// RemoteMessage is the underlying remote message. Other interceptors in the
// generic chain may have observed it already.
RemoteMessage *RemoteMessage
// Request is the tools/call request this response is replying to.
// Available so interceptors can correlate input and output without
// re-parsing.
Request *ToolsCallRequest
// Result is the decoded tools/call result when upstream returned a
// JSON-RPC success response. Check Result.IsError to distinguish
// tool-level failures (the tool ran and reported an error) from tool-
// level successes. Mutually exclusive with Error — exactly one of Result
// and Error is non-nil.
Result *mcp.CallToolResult
}
ToolsCallResponse is a "tools/call"-specific view over the remote message carrying the response. Instances are constructed by the proxy and passed to each ToolsCallResponseInterceptor after the generic RemoteMessageInterceptor chain has run.
type ToolsCallResponseInterceptor ¶
type ToolsCallResponseInterceptor interface {
// InterceptToolsCallResponse is called with the parsed tools/call
// response. Implementations may inspect call and should return a
// non-nil error to reject the response; the interceptor's error is
// surfaced to the user instead of the upstream payload.
InterceptToolsCallResponse(ctx context.Context, call *ToolsCallResponse) error
// Name returns a stable identifier for this interceptor, used for tracing
// span attributes and log correlation.
Name() string
}
ToolsCallResponseInterceptor runs for each "tools/call" JSON-RPC response returned by the remote MCP server, after the generic RemoteMessageInterceptor chain has completed and before the response is relayed to the user.
The current contract is inspection and rejection: implementations may observe call and return a non-nil error to reject the response. Rejection produces a JSON-RPC error envelope back to the user with the originating tools/call request id — on the JSON path as the response body, on the SSE path as a substitute event in place of the rejected terminal event. Returning a *RejectError lets the interceptor pick the JSON-RPC error code, message, and data; returning a plain error falls back to a generic mapping (see RejectErrorFromCause).
Payload mutation is not yet supported — changes to call.Request, call.Result, or call.Error are silent no-ops and the response body is relayed verbatim. Typed setters for payload modification will be introduced when modification becomes a requirement.
Responses to non-"tools/call" requests are not routed to this interface; implement RemoteMessageInterceptor for RPC-agnostic hooks.
type ToolsListRequest ¶
type ToolsListRequest struct {
// Params is the decoded tools/list params. Per the MCP spec, params may
// be omitted entirely for tools/list; in that case Params is a
// zero-valued [mcp.ListToolsParams].
Params *mcp.ListToolsParams
// UserRequest is the underlying request. Other interceptors in the generic
// chain may already have observed it. Callers should prefer Params for
// tools/list-specific data; UserRequest is exposed for RPC-level needs (JSON-RPC
// ID, raw messages) and for forwarding control via the underlying HTTP
// request.
UserRequest *UserRequest
}
ToolsListRequest is a "tools/list"-specific view over a UserRequest. Instances are constructed by the proxy and passed to each ToolsListRequestInterceptor after the generic UserRequestInterceptor chain has run.
type ToolsListRequestInterceptor ¶
type ToolsListRequestInterceptor interface {
// InterceptToolsListRequest is called with the parsed tools/list request.
// Implementations may inspect list and should return a non-nil error to
// reject the discovery request; the interceptor's error is surfaced to
// the user and the request is not forwarded to the remote server.
InterceptToolsListRequest(ctx context.Context, list *ToolsListRequest) error
// Name returns a stable identifier for this interceptor, used for tracing
// span attributes and log correlation.
Name() string
}
ToolsListRequestInterceptor runs for each inbound "tools/list" JSON-RPC request after the generic UserRequestInterceptor chain has completed and before the request is forwarded to the remote MCP server.
The current contract is inspection and rejection: implementations may observe list and return a non-nil error to reject the tool discovery request. Rejection produces a JSON-RPC error envelope back to the user with the originating tools/list request id. Returning a *RejectError lets the interceptor pick the JSON-RPC error code, message, and data; returning a plain error falls back to a generic mapping (see RejectErrorFromCause).
Payload mutation is not yet supported — changes to list.User or list.Params are silent no-ops and the request body is forwarded verbatim. Typed setters for payload modification will be introduced when modification becomes a requirement.
Non-"tools/list" requests are not routed to this interface; implement UserRequestInterceptor for RPC-agnostic hooks.
type ToolsListResponse ¶
type ToolsListResponse struct {
// Error is the JSON-RPC protocol error when upstream returned an error
// response (e.g. "method not found"). Mutually exclusive with Result.
Error *jsonrpc.Error
// RemoteMessage is the underlying remote message. Other interceptors in the
// generic chain may have observed it already.
RemoteMessage *RemoteMessage
// Request is the tools/list request this response is replying to.
// Available so interceptors can correlate input and output without
// re-parsing.
Request *ToolsListRequest
// Result is the decoded tools/list result when upstream returned a
// JSON-RPC success response. Mutually exclusive with Error — exactly one
// of Result and Error is non-nil.
Result *mcp.ListToolsResult
}
ToolsListResponse is a "tools/list"-specific view over the remote message carrying the response. Instances are constructed by the proxy and passed to each ToolsListResponseInterceptor after the generic RemoteMessageInterceptor chain has run.
type ToolsListResponseInterceptor ¶
type ToolsListResponseInterceptor interface {
// InterceptToolsListResponse is called with the parsed tools/list
// response. Implementations may inspect list and should return a
// non-nil error to reject the response; the interceptor's error is
// surfaced to the user instead of the upstream payload.
InterceptToolsListResponse(ctx context.Context, list *ToolsListResponse) error
// Name returns a stable identifier for this interceptor, used for tracing
// span attributes and log correlation.
Name() string
}
ToolsListResponseInterceptor runs for each "tools/list" JSON-RPC response returned by the remote MCP server, after the generic RemoteMessageInterceptor chain has completed and before the response is relayed to the user.
The current contract is inspection and rejection: implementations may observe list and return a non-nil error to reject the response. Rejection produces a JSON-RPC error envelope back to the user with the originating tools/list request id — on the JSON path as the response body, on the SSE path as a substitute event in place of the rejected terminal event. Returning a *RejectError lets the interceptor pick the JSON-RPC error code, message, and data; returning a plain error falls back to a generic mapping (see RejectErrorFromCause).
Payload mutation is not yet supported — changes to list.Request, list.Result, or list.Error are silent no-ops and the response body is relayed verbatim. Typed setters for payload modification will be introduced when modification becomes a requirement.
Responses to non-"tools/list" requests are not routed to this interface; implement RemoteMessageInterceptor for RPC-agnostic hooks.
type UserRequest ¶
type UserRequest struct {
UserHTTPRequest *http.Request
JSONRPCMessages []jsonrpc.Message
// contains filtered or unexported fields
}
UserRequest captures an inbound MCP client HTTP request along with any JSON-RPC messages decoded from its body. Interceptors mutate this value before the request is forwarded to the remote MCP server.
func (*UserRequest) BodyReader ¶
func (r *UserRequest) BodyReader() io.Reader
BodyReader returns an io.Reader over the raw user request body so callers can forward the bytes upstream after ParseJSONRPCMessages has consumed the original stream.
func (*UserRequest) ParseJSONRPCMessages ¶
func (r *UserRequest) ParseJSONRPCMessages(maxBytes int64) error
ParseJSONRPCMessages reads the request body and decodes it into JSONRPCMessages. The raw body is retained so BodyReader can reproduce it for forwarding after interceptors have run. MCP Streamable HTTP POST bodies carry a single JSON-RPC request, response, or notification, but the field is a slice to leave room for future batch handling.
maxBytes caps the in-memory allocation during read; ErrBodyTooLarge is returned if the client sends more than that. The same limit is applied to user requests and remote responses so proxy memory use stays bounded on both sides. Streamed responses are not routed through this function and are not subject to this cap — see [Proxy.MaxBufferedBodyBytes].
type UserRequestInterceptor ¶
type UserRequestInterceptor interface {
// InterceptUserRequest is called with the parsed inbound request.
// Implementations may inspect req and should return a non-nil error to
// reject the request; the interceptor's error is rendered as a
// JSON-RPC error envelope back to the user and the request is not
// forwarded to the remote server.
InterceptUserRequest(ctx context.Context, req *UserRequest) error
// Name returns a stable identifier for this interceptor, used for tracing
// span attributes and log correlation.
Name() string
}
UserRequestInterceptor runs for each inbound user request after it has been parsed but before it is forwarded to the remote MCP server.
The current contract is inspection and rejection: implementations may observe req and return a non-nil error to reject the request before it reaches the remote server. Rejection produces a spec-aligned JSON-RPC error envelope back to the user — for requests, an HTTP 200 carrying an error response with the originating id; for notifications, an HTTP 400 carrying an id-less error response per MCP § Streamable HTTP transport. Returning a *RejectError lets the interceptor pick the JSON-RPC error code, message, and data; returning a plain error falls back to a generic mapping (see RejectErrorFromCause).
Payload mutation is not yet supported — changes to req.JSONRPCMessages are silent no-ops and the request body is forwarded verbatim. Header mutation on req.UserHTTPRequest.Header is the one exception and does flow to the upstream request today. Typed setters for payload modification will be introduced when modification becomes a requirement.
Source Files
¶
- cancelling_body.go
- configured_header.go
- errors.go
- headers.go
- jsonrpc.go
- metrics.go
- proxy.go
- reject_error.go
- remote_message.go
- remote_message_interceptor.go
- sse.go
- sse_substitute.go
- stream_reader.go
- tools_call_request.go
- tools_call_request_interceptor.go
- tools_call_response.go
- tools_call_response_interceptor.go
- tools_list_request.go
- tools_list_request_interceptor.go
- tools_list_response.go
- tools_list_response_interceptor.go
- user_request.go
- user_request_interceptor.go