MCP Fusion/Core concepts/Middleware and context

Middleware and context

Ask AI about Vinkius

Compose authentication, tenant isolation and rate limiting with typed context derivation: one defineMiddleware call enriches ctx for every handler downstream, compiled O(1) at build time.

Middleware is where a connector stops being a set of functions and becomes a service: identity is resolved, tenants are isolated, traffic is limited, every call is audited. MCP Fusion's middleware model is small, typed end to end, and compiled before the first request.

Two shapes, one concept

Context derivation is the idiomatic form. A middleware returns the pieces it adds to ctx:

typescript
import { initMCPFusion } from '@mcpfusion/core';

interface AppContext {
  db: PrismaClient;
  user?: { id: string; role: 'viewer' | 'admin' };
}

const f = initMCPFusion<AppContext>();

const withUser = f.middleware(async (ctx) => {
  const token = ctx.headers?.authorization;
  const user = await verify(token);
  if (!user) throw f.error('UNAUTHORIZED', 'Sign in first');
  return { user };
});

Tool use attaches it:

typescript
f.query('billing.list_invoices')
  .use(withUser)
  .handle(async (input, ctx) => {
    return ctx.db.invoices.findMany({
      where: { userId: ctx.user.id },
    });
  });

The type system carries the derivation: .use(withUser) changes the builder's context type to AppContext & { user }, so ctx.user compiles inside the handler and removing the middleware breaks the build. There is no runtime cast to discover in production.

The classic form also exists

Middleware that needs to act before and after, or short-circuit, uses (ctx, args, next):

typescript
import type { MiddlewareFn } from '@mcpfusion/core';

const audit: MiddlewareFn<AppContext> = async (ctx, args, next) => {
  const started = Date.now();
  const result = await next();
  ctx.audit?.({ tool, args, ms: Date.now() - started });
  return result;
};

Returning a ToolResponse from a middleware short-circuits: next() is never called and that response is the answer. This is how inputFirewall and rateLimit reject a call without touching the handler. Forgetting return next() triggers a one-time console warning, because every developer has made that mistake once.

Throwing works too: throw toolError('NOT_FOUND', ...) passes through the pipeline with code and recovery intact. Any other thrown value is wrapped as INTERNAL_ERROR.

Order and scope

Three scopes compose into one chain per action:

ScopeDeclared onPosition
Globalf.middleware() and registry-leveloutermost
GroupActionGroupBuilder.use()middle
Tool or action.use() on the builderinnermost

Global middleware runs first (auth before anything), per-action last (the closest to your handler). Chains are compiled in buildToolDefinition() into one closure per action, so the request path is a call, not a loop over an array.

The built-ins

Three middleware ship with the framework and are ordinary middleware, not magic:

  • inputFirewall({ judge }): LLM-as-Judge on the arguments, fail-closed, returns INPUT_REJECTED
  • rateLimit({ windowMs, limit, keyFn }): sliding window over timestamps; rejected requests are not recorded, so an abusive client cannot extend its own lockout
  • auditTrail({ sink, hashArgs }): emits one event per call with a SHA-256 digest of the arguments (never the arguments themselves)

Auth middleware comes from the dedicated packages: requireJwt, requireApiKey and requireAuth. See Authentication.

Isolation by construction

contextFactory runs per request and returns a fresh object; middleware then writes derived keys into it with a guard that skips __proto__, constructor and prototype. Two concurrent requests therefore cannot share a tenant id, a user or a database handle even if they hit the same long-lived process. On a stateless transport each request gets a fresh server; on stdio the same guarantee comes from the per-request context object.

This is the mechanism behind Multi-tenant connectors: isolation is a property of the runtime, not a discipline your handlers must remember.

Next steps