Skip to content
mcp-data-platform composable mcp data platform
v1.x part of txn2 ↗

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 --> Icons
    Icons --> DescOverrides
    DescOverrides --> ToolVisibility
    ToolVisibility --> 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 --> ToolVisibility
    ToolVisibility --> DescOverrides
    DescOverrides --> Icons
    Icons --> Response

The platform registers up to nine middleware layers. Execution flows left-to-right for requests and right-to-left for responses.

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:

  1. Only intercepts tools/call requests (passes through other methods)
  2. Extracts tool name from request parameters
  3. Creates PlatformContext with request ID and tool name
  4. Looks up toolkit metadata (kind, name, connection) via ToolkitLookup
  5. Runs authenticator to identify user (populates UserID, Email, Roles)
  6. Runs authorizer to check tool access (populates PersonaName, Authorized)
  7. Resolves the explicit session handle (issue #792) via the optional SessionResolver: extracts the session_id argument, validates it against the session store (must exist, be unexpired, and belong to the same authenticated identity), adopts it onto PlatformContext.SessionID, and strips it before the handler runs. When handles are required, only a valid platform_info-minted handle satisfies the requirement; a transport-level Mcp-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 yields SESSION_REQUIRED; an unknown, expired, or cross-identity handle yields SESSION_EXPIRED. When handles are enabled but not required, a handle-less call falls back to the transport session.
  8. 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.

func MCPAuditMiddleware(logger AuditLogger) mcp.Middleware

Behavior:

  1. Only intercepts tools/call requests
  2. Records start time
  3. Calls next handler
  4. Gets PlatformContext (set by MCPToolCallMiddleware)
  5. Builds audit event with timing, user, tool, and parameter data
  6. 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.

func MCPWorkflowGateMiddleware(tracker *SessionWorkflowTracker) mcp.Middleware

Behavior:

  1. Only intercepts tools/call requests
  2. If the tool is not a gated query tool, or the scope has already performed discovery, the call proceeds unchanged
  3. Otherwise the middleware short-circuits with a SEARCH_REQUIRED error result and never invokes the tool handler, so the query does not execute
  4. Once search has 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 search retries 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).

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

func MCPSessionHandleSchemaMiddleware(initTool string) mcp.Middleware

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.

func MCPDescriptionOverrideMiddleware(overrides map[string]string) mcp.Middleware

Behavior:

  1. Only intercepts tools/list responses
  2. For each tool in the response, replaces its description if a matching override exists
  3. 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,
) mcp.Middleware

Behavior:

  1. Only intercepts tools/call requests
  2. Calls next handler to get result
  3. Skips enrichment if result is error
  4. Determines toolkit kind from tool name prefix (trino_, datahub_, s3_)
  5. Calls appropriate enrichment function based on toolkit
  6. Appends semantic context to result content

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.

func MCPToolVisibilityMiddleware(allow, deny []string) mcp.Middleware

Behavior:

  1. Only intercepts tools/list responses (passes through all other methods including tools/call)
  2. Calls next handler to get the full tool list
  3. Filters tools using filepath.Match patterns
  4. No patterns configured = all tools visible
  5. Allow only = only matching tools pass
  6. Deny only = all tools pass except denied
  7. 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.

func MCPIconMiddleware(cfg IconsMiddlewareConfig) mcp.Middleware

Behavior:

  1. Intercepts list responses (tools/list, resources/templates/list, prompts/list)
  2. Matches tools/resources/prompts by name or URI
  3. Appends configured icons to matching entries
  4. 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:

  1. Only active for tools/call requests
  2. Sends log messages using the server session's LoggingMessage method
  3. Messages include enrichment details, query timing, and semantic cache hits
  4. 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.

func ToolMetadataMiddleware(reg *mcpapps.Registry) mcp.Middleware

Behavior:

  1. Intercepts tools/list responses (not tools/call)
  2. 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.