middleware

package
v2.0.23 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

Receiving middleware

The MCP receiving chain: what a caller is allowed to do (tools/call) and what they are allowed to see (tools/list). Both delegate every decision to security-proxy-auth — the MCP service never evaluates policy itself.

This package holds the mechanisms: argument decoding (DecodeArguments), request logging (Logging), tools/call authorization (Auth), tools/list visibility filtering (Visibility) and the whole-call deadline (Deadline). The installing service resolves their dependencies and fixes the order they install in (central-mcp's internal/controller/middleware is the reference wiring), under two positional contracts:

  • Deadline must nest outermost, the only position it works from: its context.WithTimeout has to be on the context every layer below uses.
  • DecodeArguments must nest innermost — see below.
What the ceiling bounds

ToolCallCeiling (30 s, deadline.go) covers the authorization sub-call, the upstream request and its body, on tools/call and on tools/list — the proxy-auth AuthRoutes batch rides on the latter.

It is not the only bound underneath. tools/call narrows the proxy-auth call to authz.authorizationTimeout (5 s, strictly under this one, with the measurement beside it); tools/list does not, because its AuthRoutes call is the last thing on that path and has no budget after it to protect.

The 30 s comes from a measurement: for a measured core-data call the wait for the response to begin is ~99.9% of the call. ⚠ It bounds the body as well as the headers, so headers at 28 s followed by a 10 s body exceeds it. Not configurable: nothing has hit it, and a knob can be set wrong.

⚠ One layer deliberately sits outside it. decode_arguments reads the schemas back under context.WithoutCancel: that readback happens once for the process and must not be latched empty by one caller's timeout.

Whose deadline ended the call

Several layers react to a cancelled context and every one needs the same distinction: our ceiling expired while the caller waited (the other side did not answer — an outage, worth a log line, invalidates a cached address) versus the caller stopped waiting (evidence about nobody, and it must page no one).

ctx.Err() cannot tell them apart — our ceiling and a caller's own deadline are both context.DeadlineExceeded — so deadline.go stamps callstate.ErrCeilingExpired as the context's cause, and callstate.Abandoned is the only place that comparison is written. Each site below carries only its own consequence; the mechanism lives in pkg/mcp/callstate.

asks so that
pkg/mcp/authz/authz.go an abandoned call is not reported as a proxy-auth outage
middleware/visibility.go a hang-up during tools/list authorization is not one either
pkg/mcp/rs/challenge.go a client that hangs up mid-introspection is not a 503

The installing service adds its own sites — central-mcp's injector/invalidate.go and tool/ping_service.go carry two more; see its middleware README.

A test that simulates the ceiling must stamp the cause. A bare context.WithTimeout is an unstamped expiry, which by definition belongs to the caller — so it exercises the hang-up branch while claiming to exercise this one. Two tests did exactly that and passed by accident.

decode_arguments must be innermost. The gate is the next thing after it, so one layer further out the gate would judge the original argument and reject the call. Its own tools/list would also be filtered by visibility into a per-caller subset and logged by logging as a request no client made.

visibility also sets cacheScope: "private" on the list it filters. Keeping that on the same result, in the same place, is what stops the declaration and the filtering from drifting apart: a separate layer that rebuilt the ListToolsResult would decide for itself which tools are on it, and could put back what visibility removed.

Route universes: one declaration, two consumers

proxy-auth's RBAC is keyed by (URI, method) — an Edge Central REST endpoint, not a tool name. So every tool must be expressed as routes before it can be authorized.

Every tool declares its route universe in its own file in the installing service's tool package — every route its arguments can reach. pkg/mcp/tool is the registration framework; central-mcp's middleware README shows a declaration.

Only tools/list reads this, via tool.Routes(). It has no arguments, so it cannot know which single route a call would take, and must decide visibility against the whole set.

tools/call needs no route at all. It used to resolve one from the arguments and authorize that, which made the resolved route and the dispatched route two copies of one decision; authorization now happens in pkg/mcp/authz, inside the transport each upstream client is built with, against the request being sent. So an inaccurate universe can only show or hide a tool in the catalogue — it can no longer permit a call.

A tool declaring neither routes nor Local panics at registration. An empty universe is fail-closed, so a forgotten declaration would otherwise hide the tool with nothing reporting it — and deriving "no routes means local" would invert that into showing it to everyone.

tools/list filtering

tools/list carries no arguments, so there is no single route to authorize against — visibility is decided against the whole universe.

  1. Call next to get the real catalogue.
  2. Collect every listed tool's universe into one deduplicated batch.
  3. One POST to proxy-auth /api/v3/oauth/auth-routes — a single introspection plus one Casbin BatchEnforce. Always answers 200 with a per-route authResult array; never 204/403.
  4. Keep a tool when any of its routes is allowed.
Union, not intersection

A user who may GET but not DELETE still sees manage_filters, because some of its actions are available to them. An intersection would hide a tool a partially-privileged user can genuinely use, and the over-reaching action is still refused per-call by rbac. Visibility is a discovery filter; rbac is the enforcement boundary. Hiding a tool never grants anything.

Local tools

A tool with no upstream route to authorize — route-authz cannot speak to it — sets Local: true in its own file and stays visible, resting on endpoint-level bearerAuthn having required a valid token. The marking is declared, never inferred — see the panic above. Which tools are Local, and why nothing authorizes each of them, is the installing service's to document — see central-mcp's middleware README for its three.

A Local tool may call an upstream. That matters because authz.Transport authorizes ServicePrefix + req.URL.EscapedPath() of the request actually being sent, which is what makes every route-mapped tool immune to a caller splicing path segments into an upstream URL. A Local tool has no such backstop, so any caller-controlled value it puts in an upstream path must be validated by the tool itself — central-mcp's ping_service does that with a positive character set (internal/tool/ping_service.go, isOneServiceName). A Local tool that calls an upstream without that validation is exploitable by any caller holding a token.

For the same reason such a tool must not pass an upstream error back verbatim: it carries the upstream's own host and port, and on a 5xx its response body, to a caller no route authorization ran on. Log the cause, return a message that names only the upstream and the argument.

Fail-closed

Every failure path yields no list rather than an unfiltered one:

Situation Result
proxy-auth not in configuration every tools/list rejected (RejectToolsList)
no Authorization header Unauthorized
token invalid/expired (401) Unauthorized
outage / 5xx / 404 / timeout ServerError — an outage is not a decision
tool with no universe and not local dropped

The error returned to the caller is deliberately generic; the client's real error is logged instead, so an upstream 404 on the batch endpoint stays diagnosable without leaking proxy-auth internals over the MCP protocol.

Why not the go-mod-central-ext client

AuthClient.Auth/AuthRoutes hardcode the first-party /api/v3/auth and /api/v3/auth-routes paths, which reject OAuth tokens. Both clients — this package's NewAuthRoutesClient and pkg/mcp/authz's — are hand-rolled against the /api/v3/oauth/* equivalents, and take an AuthenticationInjector from the installing service; that injector must stamp nothing (central-mcp's passthroughInjector) so the service's own JWT never overwrites the forwarded end-user bearer.

Known limitations

  • No notifications/tools/list_changed. A permission change mid-session is not pushed; the client sees it on its next tools/list. This is a staleness issue, not a security one — nothing here is cached, and rbac re-authorizes every tools/call, so a revocation takes effect immediately on execution even while a client still shows the tool. "Nothing is cached" is a claim about the ttlMs: 0 the service ships, not about the protocol: set a positive ttlMs and the staleness window becomes exactly that long — which is why the list is declared private now rather than when someone reaches for the speed-up.
  • Filtering does not refill a page. Safe only while the whole surface fits in one page — the SDK's DefaultPageSize is 1000. Set a smaller PageSize, or grow past it, and a caller whose first page is entirely denied gets an empty Tools with a non-empty NextCursor.

Documentation

Overview

Package middleware holds the receiving-side MCP middlewares an MCP resource server installs: request logging, tools/call authorization (Auth) and tools/list visibility filtering (Visibility).

Index

Constants

View Source
const OAuthAuthRoutesPath = "/api/v3/oauth/auth-routes"

OAuthAuthRoutesPath is proxy-auth's batch OAuth route-authz endpoint.

View Source
const ToolCallCeiling = 30 * time.Second

ToolCallCeiling bounds the authorization sub-call, the upstream request and its body — not any registry/address lookup a service resolves outside the call.

Variables

This section is empty.

Functions

func Auth

func Auth(lc log.Logger) sdkmcp.Middleware

Auth attaches the caller's bearer token to the context of every tools/call and reports the authorization outcome of whatever upstream calls the tool then makes.

It does not decide anything. The decision is made once, per outgoing request, inside authz.Transport — which is looking at the request being sent rather than at a prediction of it. This middleware exists so the caller's identity reaches that transport, and so a refusal reads to the model as a refusal.

Local tools have no route-authorized upstream call. Endpoint-level bearerAuthn has already required a valid token, but any upstream access must perform the additional validation documented for Local tools.

func Deadline

func Deadline(ceiling time.Duration) sdkmcp.Middleware

Deadline bounds every ceilinged method at ceiling, on the caller's own context. ceiling is a parameter so a test can pass 50 ms instead of waiting 30 s. ⚠ Register it LAST so it nests OUTERMOST — only from there does it bound the whole call.

func DecodeArguments

func DecodeArguments(lc log.Logger) sdkmcp.Middleware

DecodeArguments accepts an array or object argument that a client sent as a JSON string. Claude Desktop and the claude.ai connector serialise every argument declared as an array or object, leaving scalars alone, and the SDK's schema gate rejects those calls before any tool handler runs.

It must stay innermost (installed first, see the middleware chain): it reads the advertised schemas back through `next`, which outside Visibility would return a per-caller subset and outside Logging would log a request no client made.

func Logging

func Logging(lc log.Logger) sdkmcp.Middleware

Logging returns a pass-through middleware that logs every incoming MCP request's method, tool name (when method is tools/call), duration, an approximate response-token count, and error.

func RejectToolsList

func RejectToolsList(err error) sdkmcp.Middleware

RejectToolsList fails every tools/list with err while passing other methods through. tools/call is left alone deliberately: it is refused further down, by the transport, so rejecting it here as well would be a second copy of one decision.

func Visibility

func Visibility(lc log.Logger, client RouteAuthorizer, toolRoutes func() map[string][]tool.Route, isLocal func(string) bool, resource string) sdkmcp.Middleware

Visibility filters tools/list down to the tools the calling user may actually use: it batch-authorizes the union of every listed tool's declared route universe and keeps a tool when any one route is allowed, and declares the result private so no intermediary may forward one caller's catalogue to the next. Union semantics, the local-tool exemption, the fail-closed paths and the pagination caveat are all explained in README.md.

Types

type AuthRoute

type AuthRoute struct {
	Path string `json:"path" validate:"required,dto-none-empty-string"`
	// The Method oneof constraint must stay in sync with tool.validMethods, which
	// Register enforces at startup (see pkg/mcp/tool/registry.go).
	Method string `json:"method" validate:"required,oneof=GET HEAD POST PUT DELETE CONNECT OPTIONS TRACE PATCH QUERY MUTATION SUBSCRIPTION"`
}

type AuthRouteResult

type AuthRouteResult struct {
	AuthRoute  `json:",inline"`
	AuthResult bool `json:"authResult"`
}

AuthRouteResult defines the content for auth route result

type RouteAuthorizer

type RouteAuthorizer interface {
	AuthRoutes(ctx context.Context, headers map[string]string, routes []tool.Route) ([]AuthRouteResult, error)
}

RouteAuthorizer is the proxy-auth subset tools/list filtering needs: authorize a whole route set for the bearer's user in one round-trip.

func NewAuthRoutesClient

func NewAuthRoutesClient(baseURL string, injector restinterfaces.AuthenticationInjector) RouteAuthorizer

NewAuthRoutesClient builds the proxy-auth batch route-authz client Visibility uses.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL