Skip to main content
Middleware runs for tool calls and governed resource, prompt, and completion operations. Use it for access control, logging, request shaping, and consistent policy enforcement. New middleware receives one unified ctx object plus next. If you need finer-grained hooks (only on specific tools), see Hooks.

When to use middleware

Use middleware when you need a decision or transformation to apply to every request:
  • Authentication and authorization checks.
  • Input normalization or validation.
  • Logging, metrics, and tracing tags.
  • Guardrails for destructive or high-cost tools.
If the logic is only for a single tool, operation, or server, prefer proxy.tool(...), proxy.operation(...), or proxy.mcp(name).operation(...) routes to avoid slowing the full pipeline.

Basic middleware

proxy.use(async (ctx, next) => {
  ctx.log.info("tool-call", {
    subject: ctx.subject?.id ?? "anonymous",
    authenticated: ctx.auth.authenticated,
    allowed: ctx.policy.allowed,
  });

  return next();
});
Why it matters: logging in middleware ensures every tool call is observed, regardless of which server it hits.

Deny a tool

proxy.tool("filesystem.delete", async (ctx) => {
  return ctx.deny("delete is disabled for this environment");
});
Tool patterns use public server.tool names such as github.create_issue, github.*, and *.search_*.

Server-scoped middleware

const github = proxy.mcp("github");

github.use(async (ctx, next) => {
  if (ctx.subject?.hasGroup("support") === false) {
    return ctx.deny("GitHub tools require support group membership.");
  }

  return next();
});
Tip: use a centralized allow/deny list to avoid scattering policy rules across multiple middlewares.

Check policy capabilities

proxy.use(async (ctx, next) => {
  if (ctx.subject?.hasGroup("admins") && ctx.policy.can("github", "delete_repo")) {
    ctx.log.info("admin-delete-capability", {
      subject: ctx.subject.id,
      groups: ctx.subject.groups.map((group) => group.id),
    });
  }

  return next();
});
ctx.policy.can(server, tool) checks the configured allow/deny permissions for the current subject. It does not consume rate limits, invoke manual approval callbacks, or expose credential values.

Inject guidance

proxy.use(async (ctx, next) => {
  ctx.inject("Remember to avoid destructive actions.");
  return next();
});
This is useful when you want the model to be aware of environment-specific constraints (for example: “Only read from /tmp”).

Enforce plan-based policies

proxy.use(async (ctx, next) => {
  if (ctx.subject?.metadata?.plan !== "enterprise" && ctx.tool?.proxyName === "analytics__export") {
    return ctx.deny("Export is available only on the enterprise plan.");
  }
  return next();
});

Validate inputs before they reach servers

You can block requests early by checking parameters. This is useful when a server does not validate inputs on its own.
proxy.tool("*.search_*", async (ctx, next) => {
  if (typeof ctx.args?.query === "string") {
    if (ctx.args.query.length > 200) {
      return ctx.deny("Query too long.");
    }
  }
  return next();
});

Normalize inputs

Middleware can also clean up incoming parameters before they reach a server.
proxy.tool("*.search_*", async (ctx, next) => {
  if (typeof ctx.args?.query === "string") {
    ctx.args.query = ctx.args.query.trim();
  }
  return next();
});

Add soft guidance for LLM clients

If your MCP clients are LLMs, you can inject guidance without blocking requests. This is useful for reminders like “do not write outside /tmp”.
proxy.use(async (ctx, next) => {
  ctx.inject("Use read-only tools unless explicitly asked to write.");
  return next();
});

Pattern: lightweight rate limiting

Fentaris does not implement rate limiting directly, but you can implement a simple per-user limit inside middleware. For production, use a shared store (Redis, KV) to coordinate across multiple instances.
const calls: Record<string, number> = {};

proxy.use(async (ctx, next) => {
  const userId = ctx.subject?.id ?? ctx.auth.userId ?? "anonymous";
  calls[userId] = (calls[userId] ?? 0) + 1;
  if (calls[userId] > 1000) {
    return ctx.deny("Rate limit exceeded.");
  }
  return next();
});

Migrating legacy middleware

proxy.use(async (request, context, next) => {
  context.log.info("legacy", { tool: request.proxyToolName });
  return next();
});

proxy.use(async (ctx, next) => {
  ctx.log.info("unified");
  return next();
});
ctx.user, ctx.policyDecision, and ctx.res remain available as compatibility aliases. Prefer ctx.subject, ctx.auth, ctx.policy, ctx.credentials, ctx.response, and the root helpers in new code.

Debugging middleware

If requests are not behaving as expected, log ctx.operation, ctx.server?.name, ctx.tool?.proxyName, ctx.resource?.uri, ctx.prompt?.name, and ctx.completion?.target. This will quickly tell you whether a route is matching the capability you expect.

Local capabilities

Local capabilities declared with app.local(name) use the same middleware and operation route pipeline as upstream MCP servers.
app.local("workspace")
  .tool("status", { inputSchema: { type: "object" } }, async (ctx) => ({
    content: [{ type: "text", text: ctx.server?.name ?? "workspace" }],
  }));

app.mcp("workspace").use(async (ctx, next) => {
  ctx.log.info("workspace.local", {
    operation: ctx.operation,
    tool: ctx.tool?.name,
    resource: ctx.resource?.uri,
    prompt: ctx.prompt?.name,
  });

  return next();
});
For local tool handlers, ctx.server?.name is the local namespace and ctx.tool?.name is the unproxied local tool name. Local resources, prompts, and completions expose the same context fields as upstream operations.

Ordering rules

Middleware runs in registration order. If a middleware returns a CallToolResult, Fentaris stops the chain.

Best practices

  • Keep side effects minimal; prefer deterministic checks.
  • Log denied calls with the reason to ease audits.
  • Avoid long-running operations; do those in the upstream server when possible.
  • Use one middleware per concern (logging, policy, normalization).
  • Write tests for policy middleware to avoid regressions.