Middleware Reference¶
Middleware processes requests and responses at the MCP protocol level. Each middleware intercepts tools/call requests before they reach tool handlers, and can process responses on the way back.
Middleware Architecture¶
graph LR
Request --> ResultType
ResultType --> Icons
Icons --> DescOverrides
DescOverrides --> ToolVisibility
ToolVisibility --> OutputSchema
OutputSchema --> AppsMetadata
AppsMetadata --> MCPToolCall
MCPToolCall --> MCPWorkflowGate
MCPWorkflowGate --> MCPAudit
MCPAudit --> ClientLogging
ClientLogging --> MCPEnrichment
MCPEnrichment --> Handler
Handler --> MCPEnrichment
MCPEnrichment --> ClientLogging
ClientLogging --> MCPAudit
MCPAudit --> MCPWorkflowGate
MCPWorkflowGate --> MCPToolCall
MCPToolCall --> AppsMetadata
AppsMetadata --> OutputSchema
OutputSchema --> ToolVisibility
ToolVisibility --> DescOverrides
DescOverrides --> Icons
Icons --> ResultType
ResultType --> Response
The platform registers up to eleven middleware layers. Execution flows left-to-right for requests and right-to-left for responses. ResultType is the outermost layer and types every result the chain hands back; the canonical list, with each layer's ordering dependencies, is receivingMiddlewareChain in pkg/platform/middleware_chain.go.
MCP Middleware Interface¶
// MCP middleware signature from the go-sdk
type Middleware func(next MethodHandler) MethodHandler
type MethodHandler func(ctx context.Context, method string, req Request) (Result, error)
AddReceivingMiddleware Ordering¶
server.AddReceivingMiddleware() wraps the current handler, making each newly-added middleware the outermost layer. The last middleware added runs first.
To achieve the desired execution order, middleware must be added innermost-first:
// Desired execution: Visibility → Apps → Auth → WorkflowGate → Audit → Enrichment → handler
// Add order (innermost first):
server.AddReceivingMiddleware(enrichment) // innermost
server.AddReceivingMiddleware(audit)
server.AddReceivingMiddleware(workflowGate) // search-first gate (#787)
server.AddReceivingMiddleware(auth) // outermost for tools/call
server.AddReceivingMiddleware(apps) // apps metadata
server.AddReceivingMiddleware(visibility) // overall outermost (if configured)
This ordering is critical for context propagation. Go's context.WithValue creates a new context. Values set in an outer middleware (like PlatformContext set by Auth) are visible to inner middleware (like Audit), but not the other way around.
Platform Context¶
The platform context carries request-scoped data through the middleware:
type PlatformContext struct {
// Request identification
RequestID string
StartTime time.Time
// User information
UserID string
UserEmail string
UserClaims map[string]any
Roles []string
PersonaName string
// Tool information
ToolName string
ToolkitKind string
ToolkitName string
Connection string
// Authorization
Authorized bool
AuthzError string
// Results (populated after handler)
Success bool
ErrorMessage string
Duration time.Duration
}
// Get context from request context
pc := middleware.GetPlatformContext(ctx)
// Set context in request context
ctx = middleware.WithPlatformContext(ctx, pc)
Built-in Middleware¶
MCPToolCallMiddleware¶
Handles authentication, authorization, and toolkit metadata lookup at the MCP protocol level. Creates the PlatformContext that all inner middleware depends on.
func MCPToolCallMiddleware(
authenticator Authenticator,
authorizer Authorizer,
toolkitLookup ToolkitLookup,
) mcp.Middleware
Behavior:
- Only intercepts
tools/callrequests (passes through other methods) - Extracts tool name from request parameters
- Creates PlatformContext with request ID and tool name
- Looks up toolkit metadata (kind, name, connection) via
ToolkitLookup - Runs authenticator to identify user (populates UserID, Email, Roles)
- Runs authorizer to check tool access (populates PersonaName, Authorized)
- Resolves the explicit session handle (issue #792) via the optional
SessionResolver: extracts thesession_idargument, validates it against the session store (must exist, be unexpired, and belong to the same authenticated identity), adopts it ontoPlatformContext.SessionID, and strips it before the handler runs. When handles are required, only a validplatform_info-minted handle satisfies the requirement; a transport-levelMcp-Session-Id(or the stdio sentinel) is not accepted as a fallback (issue #800), because it is the churning per-call value the handle exists to replace. A missing handle on a gated tool yieldsSESSION_REQUIRED; an unknown, expired, or cross-identity handle yieldsSESSION_EXPIRED. When handles are enabled but not required, a handle-less call falls back to the transport session. - Returns error result if auth or session resolution fails, otherwise proceeds
The toolkitLookup parameter is optional; if nil, toolkit metadata fields remain empty. The session resolver is supplied via ToolCallConfig.SessionResolver and is a valid nil no-op when explicit handles are disabled.
MCPAuditMiddleware¶
Logs tool calls for compliance and debugging. See Audit Logging for full documentation.
Behavior:
- Only intercepts
tools/callrequests - Records start time
- Calls next handler
- Gets PlatformContext (set by MCPToolCallMiddleware)
- Builds audit event with timing, user, tool, and parameter data
- Logs asynchronously in a goroutine (does not block response)
If PlatformContext is nil (auth middleware didn't run or middleware is misordered), audit logging is skipped with a warning.
MCPWorkflowGateMiddleware¶
Enforces the search-first hard gate (issue #787): a query tool call is refused until a discovery tool (search, by default) has been called at least once in the session.
Behavior:
- Only intercepts
tools/callrequests - If the tool is not a gated query tool, or the scope has already performed discovery, the call proceeds unchanged
- Otherwise the middleware short-circuits with a
SEARCH_REQUIREDerror result and never invokes the tool handler, so the query does not execute - Once
searchhas been called once in a scope, subsequent query tool calls by that scope proceed with no further check (see User-scoped discovery signal below)
Modeled on MCPSessionGateMiddleware: it is positioned inner to MCPToolCallMiddleware (so PlatformContext is populated and the current call is recorded on the tracker) and outer to audit/enrichment (so a blocked call never reaches those layers). Enabled by default; disabled only when workflow.require_search: false leaves the tracker unconfigured. The former MCPRuleEnforcementMiddleware (a warn-after-execution mechanism) and the static tuning.rules.require_datahub_check hint have been removed.
User-scoped discovery signal: discovery is tracked per PlatformContext.DiscoveryScopeKey: a genuinely distinct authenticated user identity when known (user:<id>), falling back to the session ID (session:<id>), and empty (ungateable, fail-open) when neither exists. Keying on the user rather than the raw session ID is deliberate: some MCP clients (notably claude.ai's web connector) open a new MCP session for every tool call, so a session-keyed gate would record search under one throwaway session and check the follow-up query under a different one, a 100% false SEARCH_REQUIRED even though the agent did search. Scoping to the user makes a single search open the gate for that user's subsequent per-call sessions. Only a distinct identity is used: the shared anonymous/noop identity assigned when auth is disabled is not distinct (every caller shares one UserID), so those callers fall back to the per-session key rather than collapsing onto one global scope. The tradeoff is that, for authenticated users, discovery is per-user-recent rather than strictly per-session, so a stable-session client's second conversation can inherit the first's discovery within the sliding window; this is acceptable for a workflow-quality nudge. A deployment with authentication disabled falls back to per-session gating for every caller; if such a deployment is also behind a client that opens a new session per tool call, there is no stable identity to gate on and the gate should be disabled (workflow.require_search: false).
Replica-shared discovery state (#789): the per-scope "has performed discovery" signal lives in a searchgate.Store. When a database is configured the tracker uses the Postgres store (pkg/searchgate/postgres), so discovery recorded on one replica is visible to a query handled by another; without a database it uses an in-memory store, which is correct only for single-replica deployments. Semantics:
- Single source of truth: the shared store is authoritative; the tracker holds no replica-local "discovered" bit that could diverge from it, so the gate is consistent across replicas.
- Sliding window: ongoing query activity by a discovered session refreshes the shared record (store writes throttled to at most once per half of the session timeout), so a long active session is not re-gated mid-workflow.
- Write resilience: a failed discovery write persists nothing (there is no divergent local state); a forced discovery write always re-attempts, so the agent's next
searchretries persistence once writes recover. - Fail-open on read error (deliberate): the gate decision follows the read. A total store outage (reads fail) fails open (allows queries, logged) rather than blocking every one, since the gate is a workflow quality guard, not a security boundary.
- Fail-closed on write outage (deliberate): a store that accepts reads but rejects writes leaves discovery un-persisted, so the read returns not-discovered and the caller is gated (
SEARCH_REQUIRED) until writes recover. This is intentional: failing open on a write error would let one caller's transient write blip open the gate for everyone. If queries must proceed during a store-write outage, disable the gate (workflow.require_search: false).
MCPResultTypeMiddleware¶
Guarantees that every tools/call, prompts/get and resources/read result carries the resultType the negotiated MCP protocol revision requires (issues #1382, #1383). Revision 2026-07-28 requires the field on every such result; a client on that revision rejects a result without it as invalid, and whatever the result said is lost.
The SDK stamps resultType: "complete" on a result inside its own method handler, which is the innermost layer of the chain. A middleware that builds a result of its own never passes that layer: a refusal short-circuited by the gates (authz, session handle, search-first, purpose, rate limit), the error contract's normalized replacement of a bare error result, and a managed resource read all answer with a fresh result the SDK never typed. This middleware is therefore registered outermost, so it sees the final result whatever built it, and applies the SDK's own rule: for a session negotiated at 2026-07-28 or later the result is complete unless it carries input requests, and for an older client the field is left unset, exactly as the SDK leaves it.
MCPOutputSchemaMiddleware¶
Opens the top level of every output schema a tools/list response advertises (issue #1381), so the schema admits the keys the platform adds to a tool's structuredContent after the tool's own handler has returned: the {error} envelope the error contract substitutes on failure, the call_reference appended to a data call, and the context blocks semantic enrichment mirrors in.
A toolkit that registers a typed handler with no explicit output schema (mcp-trino did before v1.4.0) gets one inferred from its Go struct, and jsonschema-go closes every struct-derived object with additionalProperties: false and a required list; a client validating against that schema discarded every Trino result. The decorator gives each advertised object schema the same contract middleware.OpenToolOutputSchema gives the platform-owned tools: additionalProperties allowed, nothing required, and the error envelope documented under error. Every object nested beneath the top level is advertised open too (issue #1945): a client caches tools/list and validates later results against it, so a closed nested object turned a field added in the next release (platform_info's notices gained two) into a rejected platform_info for every session that listed tools before the upgrade. A nested required list is kept; only an unknown key stops being a failure. The server's registry is never mutated (the listed Tool is replaced with a copy), and the SDK still validates a handler's own structured output against the schema it inferred, inside the handler wrapper and before any middleware runs. A tool that declares a non-object schema, or none, is listed as it is.
MCPSessionHandleSchemaMiddleware¶
Advertises the explicit session handle (issue #792) by injecting a session_id string property into every tool's input schema on tools/list responses, except the init tool (platform_info, which mints the handle and takes no session_id).
A list decorator like ToolMetadataMiddleware: it replaces each Tool in the response with a shallow copy carrying the augmented schema, so neither the server's shared tool registry nor upstream toolkits (mcp-trino, mcp-datahub, mcp-s3, gateway-proxied tools) are ever modified. Any schema representation (*jsonschema.Schema, json.RawMessage, or a map) is normalized via a JSON round-trip. Registered only when sessions.handles.enabled is on. The complementary extraction, validation, and stripping happens inside MCPToolCallMiddleware (see step 7 above).
MCPDescriptionOverrideMiddleware¶
Replaces tool descriptions in tools/list responses to inject workflow guidance (e.g., "call datahub_search first"). Built-in overrides for trino_query and trino_execute are always active; config overrides take precedence.
Behavior:
- Only intercepts
tools/listresponses - For each tool in the response, replaces its description if a matching override exists
- Non-matching tools are unchanged
MCPSemanticEnrichmentMiddleware¶
Adds cross-service context to tool results.
func MCPSemanticEnrichmentMiddleware(
semanticProvider semantic.Provider,
queryProvider query.Provider,
storageProvider storage.Provider,
cfg EnrichmentConfig,
memoryProvider MemoryProvider,
pageProvider ...KnowledgePageProvider,
) mcp.Middleware
Behavior:
- Only intercepts
tools/callrequests - Calls next handler to get result
- Skips enrichment if result is error
- Skips the export tools (
trino_export,api_export, ...), whose result is asset metadata rather than rows: enriching it would describe the source tables the response does not contain - Determines toolkit kind from tool name prefix (
trino_,datahub_,s3_) - Calls appropriate enrichment function based on toolkit
- Appends semantic context to result content, and merges it into the structured result
Blocks appended to a structured result¶
Semantic enrichment, the proven queries a describe carries, and the call
reference each append a JSON text block and merge the same block into
structuredContent, so a client that renders only structured output receives
them beside what the tool returned.
The merge happens only into a structured result the tool's own handler set, and only when that result is a JSON object. A handler that returned none keeps its response as it wrote it: a structured result synthesized from the appended blocks alone is not context added to the tool's output, it is the tool's output replaced by the platform's additions, and a structured-output client then reads a response containing nothing it called the tool for.
trino_export is the tool this rule was written against. While it registered
through the untyped Server.AddTool path the SDK wrote no structured result,
so its payload (asset_id, portal_url, row_count, size_bytes) was a text
block and the appended call_reference arrived as a second text block beside
it; a client that concatenates text blocks handed the agent two JSON documents
run together (#1589). It now registers through the generic mcp.AddTool with
a typed output, as api_export does, so the SDK writes its output as the
structured result and the appended blocks merge into that one object. The rule
still applies wherever a response has no object to merge into: a
gateway-proxied tool whose upstream answered in text keeps its call_reference
in content only, as does a tool whose structured result is an array.
Tools on the untyped registration path¶
Every first-party tool registers through the generic mcp.AddTool, which
validates arguments against the input schema and writes the handler's output as
the structured result. TestUntypedToolRegistrationInventory (test/structure/verify_test.go)
scans pkg/, internal/ and cmd/ for a registration through the untyped
Server.AddTool and fails on any that is not listed with a reason. The list:
| Registration | Reason |
|---|---|
pkg/toolkits/gateway/toolkit.go, the forwarder for each tool an upstream MCP server exposes |
The input schema and result shape are the upstream's, discovered at connect time; there is no Go type to register an output as. The forwarder relays the upstream's structured result when the upstream sends one. |
MCPToolVisibilityMiddleware¶
Filters tools/list responses to hide tools that don't match configured allow/deny patterns. This reduces LLM token usage for deployments that only use a subset of toolkits. Only registered when patterns are configured.
Behavior:
- Only intercepts
tools/listresponses (passes through all other methods includingtools/call) - Calls next handler to get the full tool list
- Filters tools using
filepath.Matchpatterns - No patterns configured = all tools visible
- Allow only = only matching tools pass
- Deny only = all tools pass except denied
- Both = allow first, then deny removes from that set
This is a visibility filter, not a security boundary. Persona-level tool filtering via MCPToolCallMiddleware continues to gate tools/call independently.
MCPIconMiddleware¶
Injects config-driven icons into tools/list, resources/templates/list, and prompts/list responses. Upstream toolkits provide default icons; this middleware allows deployers to override or add custom icons via configuration.
Behavior:
- Intercepts list responses (
tools/list,resources/templates/list,prompts/list) - Matches tools/resources/prompts by name or URI
- Appends configured icons to matching entries
- Passes through all other methods unchanged
Registered by default (icons.enabled defaults to true); set icons.enabled: false to disable.
MCPClientLoggingMiddleware¶
Sends server-to-client log messages via the MCP logging/setLevel protocol. Reports enrichment decisions, timing data, and platform diagnostics. Zero overhead if the client hasn't called setLevel.
Behavior:
- Only active for
tools/callrequests - Sends log messages using the server session's
LoggingMessagemethod - Messages include enrichment details, query timing, and semantic cache hits
- No-op if the client hasn't subscribed via
logging/setLevel
Registered by default (client_logging.enabled defaults to true); set client_logging.enabled: false to disable.
ToolMetadataMiddleware (MCP Apps)¶
Injects _meta.ui fields into tools/list responses for tools that have associated MCP Apps.
Behavior:
- Intercepts
tools/listresponses (nottools/call) - For each tool with an associated MCP App, adds UI metadata to the response
Enrichment Configuration¶
type EnrichmentConfig struct {
EnrichTrinoResults bool // Add DataHub metadata to Trino results
EnrichDataHubResults bool // Add Trino query availability to DataHub results
EnrichS3Results bool // Add DataHub metadata to S3 results
EnrichDataHubStorageResults bool // Add S3 availability to DataHub results
}
Middleware Registration¶
Middleware is registered in platform.go finalizeSetup(). The add order is innermost-first because AddReceivingMiddleware makes each call the new outermost layer:
// 1. Semantic enrichment (innermost) - enriches responses
if needsEnrichment {
p.mcpServer.AddReceivingMiddleware(
middleware.MCPSemanticEnrichmentMiddleware(
p.semanticProvider, p.queryProvider, p.storageProvider, enrichCfg,
),
)
}
// 2. Audit - logs tool calls (reads PlatformContext from Auth)
if p.config.Audit.IsToolCallLoggingEnabled() {
p.mcpServer.AddReceivingMiddleware(
middleware.MCPAuditMiddleware(p.auditLogger),
)
}
// 3. Search-first gate - refuses query tools until search is called (#787).
// Short-circuits a blocked call before audit/enrichment; outer to Audit.
if p.workflowTracker != nil {
p.mcpServer.AddReceivingMiddleware(
middleware.MCPWorkflowGateMiddleware(p.workflowTracker),
)
}
// 4. Auth/Authz - creates PlatformContext (must be outer to Audit and gate)
p.mcpServer.AddReceivingMiddleware(
middleware.MCPToolCallMiddleware(p.authenticator, p.authorizer, p.toolkitRegistry),
)
// 5. MCP Apps metadata
if p.mcpAppsRegistry != nil && p.mcpAppsRegistry.HasApps() {
p.mcpServer.AddReceivingMiddleware(
mcpapps.ToolMetadataMiddleware(p.mcpAppsRegistry),
)
}
// 6. Tool visibility (overall outermost, only if patterns configured)
if len(p.config.Tools.Allow) > 0 || len(p.config.Tools.Deny) > 0 {
p.mcpServer.AddReceivingMiddleware(
middleware.MCPToolVisibilityMiddleware(p.config.Tools.Allow, p.config.Tools.Deny),
)
}
Interfaces¶
Authenticator¶
type Authenticator interface {
Authenticate(ctx context.Context) (*UserInfo, error)
}
type UserInfo struct {
UserID string
Email string
Claims map[string]any
Roles []string
AuthType string // "oidc", "apikey", etc.
}
Authorizer¶
type Authorizer interface {
// IsAuthorized checks if the user can use the tool.
// Returns:
// - authorized: whether the user is authorized
// - personaName: the resolved persona name (for audit logging)
// - reason: reason for denial (empty if authorized)
IsAuthorized(ctx context.Context, userID string, roles []string, toolName string) (authorized bool, personaName string, reason string)
}
ToolkitLookup¶
type ToolkitLookup interface {
// GetToolkitForTool returns toolkit info (kind, name, connection) for a tool.
// Returns found=false if the tool is not found in any registered toolkit.
GetToolkitForTool(toolName string) (kind, name, connection string, found bool)
}
AuditLogger¶
type AuditLogger interface {
Log(ctx context.Context, event AuditEvent) error
}
type AuditEvent struct {
Timestamp time.Time `json:"timestamp"`
RequestID string `json:"request_id"`
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
Persona string `json:"persona"`
ToolName string `json:"tool_name"`
ToolkitKind string `json:"toolkit_kind"`
ToolkitName string `json:"toolkit_name"`
Connection string `json:"connection"`
Parameters map[string]any `json:"parameters"`
Success bool `json:"success"`
ErrorMessage string `json:"error_message,omitempty"`
DurationMS int64 `json:"duration_ms"`
}
Best Practices¶
Understand AddReceivingMiddleware wrapping:
Each call makes the new middleware the outermost layer. Add innermost middleware first. If middleware B reads context values set by middleware A, then A must be outer to B (added after B).
Check method type:
Only intercept tools/call for tool-specific middleware. Pass through other methods unchanged.
Use PlatformContext: Pass request-scoped data via PlatformContext, not global variables.
Log asynchronously: Audit and logging middleware should not block the response.
Handle errors gracefully: Return MCP error results rather than Go errors for client-facing failures.