Skip to main content
Glama
README.md
# mcp-expose

[![CI](https://github.com/NITINKACHHADIYA/Node-MCP/actions/workflows/ci.yml/badge.svg)](https://github.com/NITINKACHHADIYA/Node-MCP/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/mcp-expose.svg)](https://www.npmjs.com/package/mcp-expose)
[![types](https://img.shields.io/npm/types/mcp-expose.svg)](https://www.npmjs.com/package/mcp-expose)
[![node](https://img.shields.io/node/v/mcp-expose.svg)](package.json)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

**Make your existing Node.js API agent-ready in minutes.**
`mcp-expose` turns selected HTTP routes of your **NestJS, Express, Fastify, Koa, Hono or AdonisJS** app into
[Model Context Protocol (MCP)](https://modelcontextprotocol.io) tools, so AI agents such as Claude, Cursor,
VS Code Copilot and ChatGPT can call them. Your existing **auth guards, DTO validation, rate limits and
logging keep working** because every tool call runs through your app's normal request pipeline.

```ts
// NestJS
@Get(':id')
@McpTool({ description: 'Get an order by id' })
findOne(@Param('id') id: string) { ... }

// Express / Koa / Hono
app.get('/orders/:id', mcpTool({ description: 'Get an order by id' }), auth, getOrder);

// Fastify
app.get('/orders/:id', { schema, config: { mcp: { description: 'Get an order by id' } } }, getOrder);

// AdonisJS
router.get('/orders/:id', [OrdersController, 'show']).use(middleware.auth()).mcp({ description: 'Get an order by id' });
```

- One decorator or marker per route, then add the module/plugin. No wrapper code to write.
- Zero runtime dependencies. Dual ESM/CJS. Node.js 20, 22 and 24, plus Bun, Deno and Workers for Hono.
- Supports the current **and the two previous major versions** of every framework, verified end to end.
- Speaks MCP Streamable HTTP (protocol `2024-11-05` → `2025-11-25`), stateless, so it scales horizontally and runs serverless.
- Built-in [OAuth 2.1](#oauth-sign-in-from-ai-clients): AI clients discover your identity provider and sign the user in by themselves (Auth0, Okta, Keycloak, Entra ID, Clerk, Cognito, ...).

---

## Table of contents

1. [Why this library?](#why-this-library)
2. [How it works](#how-it-works)
3. [Supported frameworks](#supported-frameworks)
4. [Installation](#installation)
5. [Framework guides](#framework-guides)
   - [NestJS](#nestjs) · [Express](#express) · [Fastify](#fastify) · [Koa](#koa) · [Hono](#hono) · [AdonisJS](#adonisjs) · [Any API via OpenAPI](#any-api-via-openapi-standalone-gateway)
6. [Connect an AI client](#connect-an-ai-client)
7. [OAuth: sign in from AI clients](#oauth-sign-in-from-ai-clients)
8. [Defining tool inputs (schemas)](#defining-tool-inputs-schemas)
9. [Shaping and enriching responses](#shaping-and-enriching-responses)
10. [Configuration reference](#configuration-reference)
11. [Custom (non-HTTP) tools](#custom-non-http-tools)
12. [Security checklist](#security-checklist)
13. [Writing tools agents use well](#writing-tools-agents-use-well)
14. [Limitations and roadmap](#limitations-and-roadmap)
15. [Development](#development)
16. [Versioning and support](#versioning-and-support)

---

## Why this library?

Many companies now want their APIs to be "agent-ready". The usual approach is a separate, hand-written MCP
server that re-implements each endpoint as a tool:

| Hand-written MCP wrapper                                               | `mcp-expose`                                                                      |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Duplicates every endpoint's input schema, auth and error handling      | Reuses the route that already exists. The route is the tool.                      |
| Needs its own auth. The easy path is often one over-privileged API key | Forwards the caller's `Authorization`/cookies, so **your guards decide** per user |
| Validation logic drifts from the real API                              | Your `ValidationPipe`, Fastify schema, zod or Joi runs on every call              |
| Rate limits and audit logs are bypassed or re-implemented              | Every tool call is a real request through your middleware                         |
| A second service to deploy, version and monitor                        | Mounted at `/mcp` inside the app you already run                                  |
| Tied to one framework                                                  | Same concepts for NestJS, Express, Fastify, Koa and Hono                          |

**Benefits for developers**

- **Minutes, not days.** Add `@McpTool()` to the endpoints you want to expose and import one module.
- **Single source of truth.** Schemas come from your DTOs, Fastify JSON schemas, zod or OpenAPI. Change the endpoint and the tool changes with it.
- **Secure by default.** Routes are opt-in. The app's own auth applies to each tool call. `Origin` checks protect against DNS rebinding.
- **Agent-friendly errors.** Your API's 400/401/404 responses are returned to the model as tool errors, so it can correct itself (for example "quantity must not be greater than 10").
- **Framework-agnostic core.** Moving from Express to Fastify, or to a NestJS monolith, keeps the same tool model.

## How it works

```
 AI client (Claude, Cursor, …)
        │  POST /mcp  {"method":"tools/call","params":{"name":"create_order","arguments":{…}}}
        │  Authorization: Bearer <user token>
        ▼
 ┌──────────────────────── your app ────────────────────────┐
 │  /mcp endpoint (mcp-expose)                               │
 │    1. finds the route behind "create_order"               │
 │    2. maps arguments → path params / query / JSON body    │
 │    3. forwards Authorization, Cookie, X-Api-Key, IP       │
 │    4. dispatches:  POST /orders  ────────────┐            │
 │                                              ▼            │
 │     middleware → auth guard → rate limit → validation → handler
 │                                              │            │
 │    5. HTTP response → MCP tool result ◄──────┘            │
 │       (2xx → content + structuredContent, 4xx/5xx → isError)
 └───────────────────────────────────────────────────────────┘
```

Dispatch uses the fastest option each framework supports safely:

- **Fastify** uses `fastify.inject()`, in process, with every hook, schema and plugin applied.
- **Hono** uses `app.request()`, in process, and runs on any runtime.
- **Express, Koa, NestJS and AdonisJS** use a loopback HTTP request to the port the MCP call arrived on, so the full stack runs, including anything outside the framework such as a reverse proxy module.

Tools are discovered **lazily on the first MCP request**, so the order you register routes and the MCP endpoint rarely matters (Fastify is the exception, see its guide).

## Supported frameworks

| Framework                         | Import                | How you mark a route           | Schema source                                        | Dispatch        |
| --------------------------------- | --------------------- | ------------------------------ | ---------------------------------------------------- | --------------- |
| NestJS (Express or Fastify)       | `mcp-expose/nestjs`   | `@McpTool()` decorator         | class-validator DTOs (+ `@ApiProperty` descriptions) | loopback        |
| Express                           | `mcp-expose/express`  | `mcpTool()` middleware         | options (JSON Schema / zod)                          | loopback        |
| Fastify                           | `mcp-expose/fastify`  | `config: { mcp }` on the route | the route's own `schema`                             | `inject()`      |
| Koa + @koa/router (or koa-router) | `mcp-expose/koa`      | `mcpTool()` middleware         | options                                              | loopback        |
| Hono                              | `mcp-expose/hono`     | `mcpTool()` middleware         | options                                              | `app.request()` |
| AdonisJS                          | `mcp-expose/adonisjs` | `.mcp()` on the route          | options, or a VineJS validator                       | loopback        |
| Any HTTP API (any language)       | `mcp-expose`          | OpenAPI `x-mcp: true`          | OpenAPI document                                     | `fetch`         |

### Version compatibility

mcp-expose supports the **current major version of each framework and the two before it**. Every row
below runs in CI as a real project (see [`e2e/`](e2e/README.md)): the packed library is installed from its
tarball and the app is driven by the official MCP SDK client, on Node.js 20, 22 and 24. Each row is checked for tool
discovery, auth forwarding, the app's own validation errors, [OAuth](#oauth-sign-in-from-ai-clients) and
[`transformResponse`](#shaping-and-enriching-responses), with and without the `oauth` option.

| Framework | Supported majors | Notes                                                                                |
| --------- | ---------------- | ------------------------------------------------------------------------------------ |
| NestJS    | **12**, 11, 10   | Express and Fastify platforms; CommonJS and ESM projects; classic and v11 `tsconfig` |
| Express   | **5**, 4, 3      | Express 3 has been unmaintained since 2015 (upgrade recommended)                     |
| Fastify   | **5**, 4, 3      | Fastify 3 is end-of-life upstream                                                    |
| Koa       | **3**, 2, 1      | Koa 1 (generator middleware) uses `mcpToolLegacy()` / `koaMcpLegacy()`               |
| Hono      | **4**, 3, 2      | Hono 2 and 3 are end-of-life upstream                                                |
| AdonisJS  | **7**, 6         | AdonisJS 7 needs Node.js 24. AdonisJS 5 is not supported (see below)                 |

**AdonisJS 5** (last release November 2022) is the only exception. It uses a different architecture:
IoC-container imports such as `@ioc:Adonis/Core/Route`, and CommonJS builds. Supporting it would need a
separate adapter. Please [open an issue](https://github.com/NITINKACHHADIYA/Node-MCP/issues) if you need it.

## Installation

```bash
npm install mcp-expose
# or: pnpm add mcp-expose / yarn add mcp-expose / bun add mcp-expose
```

Framework packages are optional peer dependencies. Use the ones you already have.
For NestJS DTO → schema generation, `class-validator` should be installed. Most Nest apps already have it.

---

## Framework guides

Each guide follows the same three steps: **1) install, 2) mark routes, 3) mount the endpoint**. Then
[connect a client](#connect-an-ai-client).

### NestJS

**Step 1: import the module** (once, in your root module):

```ts
// app.module.ts
import { Module } from '@nestjs/common';
import { McpModule } from 'mcp-expose/nestjs';

@Module({
  imports: [
    McpModule.forRoot({
      name: 'orders-api', // shown to the AI client
      version: '1.0.0',
      instructions: 'Tools for looking up and placing orders.',
      // path: 'mcp',               // default endpoint: /mcp
      // guards: [JwtAuthGuard],    // protect the MCP endpoint itself (tools/list too)
    }),
    OrdersModule,
  ],
})
export class AppModule {}
```

**Step 2: decorate the endpoints you want to expose:**

```ts
// orders.controller.ts
import { McpTool } from 'mcp-expose/nestjs';

export class CreateOrderDto {
  @IsString() sku!: string;
  @IsInt() @Min(1) @Max(10) quantity!: number;
  @IsString() @IsOptional() note?: string;
}

@Controller('orders')
@UseGuards(JwtAuthGuard)                 // ← still enforced for every tool call
export class OrdersController {
  @Get(':id')
  @McpTool({
    description: 'Get one order by its id.',
    // Optional: shape what the agent sees. Hide internal fields, add the customer from another route.
    transformResponse: async (res, ctx) => {
      if (!res.ok) return undefined; // keep the default error result
      const { internalNotes, ...order } = res.json as Order;
      const customer = await ctx.callRoute({ path: '/api/customers/:id', params: { id: order.customerId } });
      return { ...order, customer: customer.json };
    },
  })
  findOne(@Param('id', ParseIntPipe) id: number) { … }

  @Post()
  @McpTool({ name: 'create_order', description: 'Create an order. quantity must be 1-10.' })
  create(@Body() dto: CreateOrderDto) { … }   // ← input schema generated from the DTO

  @Post(':id/refund')                    // ← no @McpTool: invisible to agents
  refund(@Param('id') id: string) { … }
}
```

The generated tool input for `create_order`:

```json
{
  "type": "object",
  "properties": {
    "sku": { "type": "string" },
    "quantity": { "type": "integer", "minimum": 1, "maximum": 10 },
    "note": { "type": "string" }
  },
  "required": ["sku", "quantity"]
}
```

**Loading options from configuration:** use `forRootAsync`. `path` and `guards` are passed directly,
because they define the MCP controller:

```ts
McpModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({ name: config.get('APP_NAME'), version: config.get('APP_VERSION') }),
  guards: [JwtAuthGuard],
});
```

**Step 3: bootstrap as usual:**

```ts
// main.ts
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api', { exclude: ['mcp'] }); // optional: keep MCP at /mcp
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen(3000); // MCP: http://localhost:3000/mcp
```

Notes

- Default tool names are `<controller>_<method>` in snake_case, for example `orders_find_one`. Set `name` to override.
- The global prefix (`/api`) and URI versioning (`/v1`) are detected automatically. The MCP endpoint is version-neutral.
- Works with `@nestjs/platform-express` and `@nestjs/platform-fastify`.
- Inject `McpService` to add tools at runtime: `mcpService.server.addTool(defineTool({...}))`.
- Global guards also apply to `/mcp`. If you use a global JWT guard, MCP clients must send a token, which is usually what you want. Mark the endpoint public with your own decorator mechanism if not.

### Express

```ts
import express from 'express';
import { z } from 'zod';
import { mcpTool, mountMcp } from 'mcp-expose/express';

const app = express();
app.use(express.json());
app.set('trust proxy', 'loopback'); // req.ip = real agent IP for tool calls (rate limiting)

// Step 1: add mcpTool() as the FIRST handler of the routes to expose
app.get(
  '/products',
  mcpTool({
    name: 'search_products',
    description: 'Search the product catalog by name.',
    query: { type: 'object', properties: { q: { type: 'string' } } },
    // Optional: only send the fields the agent needs
    transformResponse: (res) =>
      res.ok ? (res.json as { items: Product[] }).items.map(({ id, name, price }) => ({ id, name, price })) : undefined,
  }),
  searchProducts,
);

app.post(
  '/orders',
  mcpTool({
    name: 'create_order',
    description: 'Place an order.',
    body: z.object({ productId: z.string(), quantity: z.number().int().min(1) }), // zod works too
  }),
  requireAuth,
  rateLimit,
  createOrder,
);

// Step 2: mount the endpoint (before or after the routes)
mountMcp(app, { name: 'shop-api', version: '1.0.0' });

app.listen(3000); // MCP: http://localhost:3000/mcp
```

**Routers mounted with a path:** Express 5 does not record mount paths, so pass them explicitly.
Express 4 detects them automatically.

```ts
const api = express.Router();
api.get('/orders', mcpTool({ description: 'List orders' }), listOrders);
app.use('/api', api);

mountMcp(app, { name: 'shop-api', routers: { '/api': api } });
```

**No-touch mode:** expose routes without editing them:

```ts
mountMcp(app, {
  name: 'shop-api',
  routes: [{ method: 'GET', path: '/orders/:id', description: 'Get an order' }],
});
```

### Fastify

```ts
import Fastify from 'fastify';
import { fastifyMcp } from 'mcp-expose/fastify';

const app = Fastify();

// Step 1: register the plugin BEFORE your routes (it listens to onRoute)
await app.register(fastifyMcp, { name: 'todo-api', version: '1.0.0' });

// Step 2: add `config.mcp` to routes. Their JSON `schema` becomes the tool schema.
app.post(
  '/todos',
  {
    schema: {
      body: {
        type: 'object',
        properties: { title: { type: 'string', minLength: 1, description: 'What needs doing' } },
        required: ['title'],
      },
    },
    config: {
      mcp: {
        name: 'add_todo',
        description: 'Add a todo item.',
        // Optional: shape the response the agent sees
        transformResponse: (res) => (res.ok ? { created: true, id: (res.json as Todo).id } : undefined),
      },
    }, // or `mcp: true`
  },
  addTodo,
);

await app.listen({ port: 3000 }); // MCP: http://localhost:3000/mcp
```

Protect the MCP endpoint itself with Fastify hooks: `register(fastifyMcp, { name, routeOptions: { onRequest: app.authenticate } })`.
The server is available as `app.mcpServer`.

### Koa

```ts
import Koa from 'koa';
import Router from '@koa/router';
import { mcpTool, mountMcp } from 'mcp-expose/koa';

const app = new Koa();
const router = new Router({ prefix: '/api' });

// Step 1: mark routes
router.get(
  '/weather/:city',
  mcpTool({
    description: 'Current weather for a city.',
    // Optional: add a hint for the agent
    transformResponse: (res) => (res.ok ? { ...(res.json as object), unit: 'celsius' } : undefined),
  }),
  getWeather,
);

// Step 2: mount the endpoint (before your routers) and list the routers to scan
mountMcp(app, { name: 'weather-api', routers: [router] });
app.use(router.routes());

app.listen(3000); // MCP: http://localhost:3000/mcp
```

`koaMcp(options)` returns the same endpoint as a plain middleware, for use with `koa-mount` or `koa-compose`.
Set `app.proxy = true` if your rate limiter keys on `ctx.ip`, so the forwarded agent IP is used.

**Koa 1** (generator middleware, koa-router 5): use the legacy helpers.

```js
const { koaMcpLegacy, mcpToolLegacy } = require('mcp-expose/koa');

router.get('/weather/:city', mcpToolLegacy({ description: 'Current weather for a city.' }), getWeather);
app.use(koaMcpLegacy({ name: 'weather-api', routers: [router] }));
app.use(router.routes());
```

### Hono

Works on Node, Bun, Deno, Cloudflare Workers and Vercel Edge. Tool calls use `app.request()`, so no network hop is involved.

```ts
import { Hono } from 'hono';
import { z } from 'zod';
import { mcpTool, mountMcp } from 'mcp-expose/hono';

const app = new Hono();

app.post(
  '/notes',
  mcpTool({
    name: 'create_note',
    description: 'Save a note.',
    body: z.object({ text: z.string() }),
    // Optional: shape the response the agent sees
    transformResponse: (res) => (res.ok ? { saved: true, id: (res.json as Note).id } : undefined),
  }),
  bearerAuth({ token }),
  async (c) => c.json(await saveNote(await c.req.json()), 201),
);

mountMcp(app, { name: 'notes-api' });

export default app; // MCP: https://<your-worker>/mcp
```

### AdonisJS

Works with AdonisJS 6 and 7. Import `mcp-expose/adonisjs` in `start/routes.ts`. That adds a `.mcp()` method to
routes, next to `.as()` and `.use()`:

```ts
// start/routes.ts
import router from '@adonisjs/core/services/router';
import { mountMcp } from 'mcp-expose/adonisjs';
import { middleware } from '#start/kernel';
import { createOrderValidator } from '#validators/order';

const OrdersController = () => import('#controllers/orders_controller');

router
  .group(() => {
    // Step 1: mark routes. Group prefixes and middleware (auth, throttle) all apply.
    router.get('orders/:id', [OrdersController, 'show']).mcp({
      description: 'Get an order by id',
      // Optional: add the customer from another route (same auth). Paths include the group prefix.
      transformResponse: async (res, ctx) => {
        if (!res.ok) return undefined;
        const order = res.json as Order;
        const customer = await ctx.callRoute({ path: '/api/v1/customers/:id', params: { id: order.customerId } });
        return { ...order, customer: customer.json };
      },
    });

    // A VineJS 4 validator (AdonisJS 7) can be passed as the schema directly
    router
      .post('orders', [OrdersController, 'store'])
      .mcp({ name: 'create_order', description: 'Place an order', body: createOrderValidator });
  })
  .prefix('/api/v1')
  .use(middleware.auth());

// Step 2: mount the endpoint
mountMcp(router, { name: 'shop-api' });
// MCP: http://localhost:3333/mcp
```

Notes

- Validation errors from `request.validateUsing()` (HTTP 422) are returned to the agent so it can correct itself.
- VineJS 3 (AdonisJS 6) cannot export JSON Schema, so pass `body` as a JSON Schema object there.
- Protect the MCP endpoint itself with `configureRoute`: `mountMcp(router, { name, configureRoute: (route) => route.use(middleware.auth()) })`.
- Resource routes: mark individual actions with `router.resource('posts', PostsController).tap('show', (route) => route.mcp({ ... }))`.
- If you use the web starter kit, exclude the MCP path from CSRF protection (`config/shield.ts`, `csrf.exceptRoutes`).

### Any API via OpenAPI (standalone gateway)

Put an MCP gateway in front of **any** HTTP API, whatever language it is written in, using its OpenAPI 3 document.
By default only operations marked `x-mcp: true` are exposed. You can also pass an `include` filter.

```ts
import express from 'express';
import { createFetchDispatcher, toolsFromOpenApi } from 'mcp-expose';
import { mountMcp } from 'mcp-expose/express';

const spec = await fetch('https://api.example.com/openapi.json').then((r) => r.json());
const tools = toolsFromOpenApi(spec, createFetchDispatcher({ baseUrl: 'https://api.example.com' }), {
  include: ({ method }) => method === 'get', // e.g. only read-only operations
  // Optional: shape responses of the generated tools (ctx.tool is the tool name)
  transformResponse: (res, ctx) => (ctx.tool === 'get_user' && res.ok ? redact(res.json) : undefined),
});

const app = express();
mountMcp(app, { name: 'example-gateway', tools });
app.listen(3000);
```

This also works with `@nestjs/swagger`, `@fastify/swagger`, tsoa and hono-openapi documents. Pass a dereferenced document, because `$ref`s are not resolved.

---

## Connect an AI client

Start your app, then point a client at `http://localhost:3000/mcp`. Pass the same credentials a normal API
client would use. They are forwarded to your routes. (With the [`oauth` option](#oauth-sign-in-from-ai-clients)
you skip the header: the client signs the user in by itself.)

**Claude Code**

```bash
claude mcp add --transport http shop-api http://localhost:3000/mcp \
  --header "Authorization: Bearer <token>"
```

**Cursor** (`~/.cursor/mcp.json` or `.cursor/mcp.json`)

```json
{
  "mcpServers": {
    "shop-api": {
      "url": "http://localhost:3000/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}
```

**VS Code (Copilot agent mode)** (`.vscode/mcp.json`)

```json
{
  "servers": {
    "shop-api": {
      "type": "http",
      "url": "http://localhost:3000/mcp",
      "headers": { "Authorization": "Bearer ${input:token}" }
    }
  },
  "inputs": [{ "id": "token", "type": "promptString", "description": "API token", "password": true }]
}
```

**Claude Desktop.** For a deployed server, add it under _Settings → Connectors_ using its public URL.
For a local server, bridge it with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "shop-api": {
      "command": "npx",
      "args": ["mcp-remote", "http://localhost:3000/mcp", "--header", "Authorization: Bearer ${API_TOKEN}"],
      "env": { "API_TOKEN": "<token>" }
    }
  }
}
```

**Debug with MCP Inspector**

```bash
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP, URL: http://localhost:3000/mcp
```

**curl smoke test**

```bash
curl -s localhost:3000/mcp -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

---

## OAuth: sign in from AI clients

Pasting a token into a client config works for development. For real users, turn on the `oauth` option:
mcp-expose then implements the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization),
so Claude, Cursor, VS Code and other clients open your normal login page, get a token for the user, and
send it on every call. Nothing is pasted by hand.

```
AI client ── POST /mcp (no token) ─────────────▶ 401  WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp"
AI client ── GET  /.well-known/oauth-protected-resource/mcp ─▶ { resource, authorization_servers: ["https://login.example.com"] }
AI client ◀──── login in the browser (your IdP: Auth0, Okta, Keycloak, Entra ID, Clerk, Cognito, ...) ────▶ access token
AI client ── POST /mcp  Authorization: Bearer <token> ─▶ mcp-expose checks the token ─▶ your route (token forwarded, guards run)
```

mcp-expose is the **resource server**. Your identity provider stays the **authorization server**, so user
accounts, login pages and MFA stay where they are.

### 1. Configure it

```ts
import { jwtVerifier } from 'mcp-expose/oauth'; // npm install jose

const oauth = {
  // The public URL of your MCP endpoint. Tokens must be issued for exactly this audience.
  resource: 'https://api.example.com/mcp',
  authorizationServers: ['https://example.auth0.com/'],
  requiredScopes: ['mcp:tools'], // optional: needed for any MCP access
  verifyToken: jwtVerifier({ issuer: 'https://example.auth0.com/' }),
};
```

Pass it as `oauth` to any adapter:

```ts
McpModule.forRoot({ name: 'shop-api', oauth, decorators: [Public()] }); // NestJS (see note below)
mountMcp(app, { name: 'shop-api', oauth }); // Express, Koa, Hono, AdonisJS
await app.register(fastifyMcp, { name: 'shop-api', oauth }); // Fastify
```

That's it. The adapter:

- serves the Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) at `/.well-known/oauth-protected-resource/mcp` (and `/.well-known/oauth-protected-resource`), public and CORS-enabled, outside any global prefix, guard or MCP middleware;
- answers requests without a valid token with `401` and a `WWW-Authenticate` challenge that starts the client's login;
- rejects tokens that were not issued for this resource (audience check), which blocks token passthrough from other services;
- forwards the verified `Authorization` header to your routes, so your guards see the same user;
- exposes the verified token to custom tools as `ctx.auth` (`subject`, `scopes`, `clientId`, `claims`).

### 2. Per-tool scopes (step-up)

```ts
@Delete(':id')
@McpTool({ name: 'cancel_order', scopes: ['orders:write'] })
cancel(@Param('id') id: string) { ... }
```

A token without `orders:write` gets `403 insufficient_scope` naming the scopes it needs (plus the ones it already has, so the new token doesn't lose access). MCP clients then ask
the user to approve the extra access and retry. All tool scopes are advertised in `scopes_supported`.
`scopes` works on every marker (`mcpTool()`, `config.mcp`, `.mcp()`, `defineTool()`).

### Token verifiers

| Verifier                                                            | Use it for                                                                                                                                                                                            |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jwtVerifier({ issuer })`                                           | JWT access tokens. Keys come from the issuer's JWKS (found through its metadata, or set `jwksUri` / `jwks`). Checks signature (asymmetric algorithms only), `iss`, `aud`, `exp`, `nbf`. Needs `jose`. |
| `introspectionVerifier({ issuer, clientId, clientSecret })`         | Opaque tokens, via [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) introspection (Keycloak, Okta, ORY, Authlete, ...). Checks `active`, `aud` and `iss`, and caches results for 60 s.              |
| `verifyToken: async (token, { resource }) => AuthInfo \| undefined` | Anything else, such as your existing session or API-key lookup. Return `undefined` to reject the token.                                                                                               |

`jwtVerifier` checks that `aud` equals `resource` by default. If your provider uses another audience
identifier (for example an Auth0 API identifier), set `audience`.

### Provider notes

- **The authorization server must support the MCP client flow:** authorization code with PKCE, and ideally
  dynamic client registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) or client ID metadata documents,
  so clients can register themselves. Many providers support this (for example Auth0, Keycloak, WorkOS and Stytch); with others, pre-register the client and give users its client ID.
- **Your routes must accept the same token.** It is forwarded unchanged. If your API normally checks a different
  audience, allow the MCP resource too, or set `forwardHeaders` to drop `authorization` and authorise
  with a custom tool using `ctx.auth`.
- **NestJS global guards** (an `APP_GUARD` that requires a session) run before the MCP controller and would answer 401 without the
  OAuth challenge. Pass your "public" decorator with `decorators: [Public()]` so the guard lets MCP requests through;
  mcp-expose checks the token itself. Likewise, don't also put auth `middleware`, `guards`, `routeOptions` hooks or `configureRoute` middleware on the MCP endpoint.
- **Behind a proxy**, `resource` must be the public URL the client connects to (`https://...`), not the internal one.

## Defining tool inputs (schemas)

The agent sees **one flat object** of arguments. mcp-expose maps each argument back to the right place:

| Argument                                             | Sent as                                   |
| ---------------------------------------------------- | ----------------------------------------- |
| name matches a path placeholder (`:id`, `{id}`)      | path segment (URL-encoded)                |
| declared in `query`                                  | query string                              |
| declared in `body`                                   | JSON body property                        |
| undeclared, route is `GET`/`DELETE`                  | query string                              |
| undeclared, route is `POST`/`PUT`/`PATCH`            | JSON body property                        |
| `body` is a non-object schema (for example an array) | the whole body, under the `body` argument |

Every schema option accepts:

- a plain **JSON Schema** object,
- a **zod 4** schema (`z.object({...})`),
- any **Standard Schema** library with JSON Schema export (valibot, arktype, …). These are also validated before the request is sent.

Sources, from highest to lowest precedence:

1. `input` in the tool options: the full schema, used as is.
2. `params` / `query` / `body` in the tool options.
3. Framework metadata: NestJS DTOs and `@Param`/`@Query` types, or Fastify route `schema`.
4. Path placeholders, as string parameters.

Your app's own validation always runs as well. The schema tells the agent what to send, and your API decides what it accepts.

## Shaping and enriching responses

By default the agent gets the route's response as it is. Add `transformResponse` to a route tool to change
what the agent sees: hide internal fields, format values, add hints, or pull in related data from other
routes. The route stays a route tool; there is no need to rewrite it as a custom tool.

```ts
@Get(':id')
@McpTool({
  name: 'get_order',
  transformResponse: async (res, ctx) => {
    if (!res.ok) return undefined; // keep the default error result
    const { internalNotes, ...order } = res.json as Order;
    // Another route of this app, with the same user's credentials, guards and pipes:
    const customer = await ctx.callRoute({ path: '/api/customers/:id', params: { id: order.customerId } });
    return { ...order, total: formatMoney(order.total), customer: customer.ok ? customer.json : null };
  },
})
findOne(@Param('id') id: string) { ... }
```

It works the same with every marker and every supported framework version: `@McpTool({ transformResponse })` (NestJS),
`mcpTool({ transformResponse })` (Express, Koa, Hono; `mcpToolLegacy()` on Koa 1), `config: { mcp: { transformResponse } }`
(Fastify), `.mcp({ transformResponse })` (AdonisJS) and `toolsFromOpenApi(doc, dispatch, { transformResponse })`.

**What the hook receives**

- `res`: `status`, `headers`, the raw `body`, the parsed `json` (for JSON responses) and `ok` (status below 400).
- `ctx.args`: the tool arguments. `ctx.request`: the internal request (method, path, query, body).
- `ctx.toolContext`: the MCP request's headers, client IP and, with [OAuth](#oauth-sign-in-from-ai-clients), `auth`.
- `ctx.defaultResult()`: the result mcp-expose would return without the hook.
- `ctx.callRoute({ method, path, params, query, body, headers })`: calls another route of the same app.
  - It forwards the same credentials (`Authorization`, cookies) and runs through the same middleware, guards and validation.
  - `path` is the full path as your server sees it, including every prefix: NestJS global prefix and URI version
    (`/api/v1/...`), Express router mount path, Koa router `prefix`, Hono `app.route()` mount, AdonisJS group `.prefix()`.
    `ctx.request.path` shows the full path of the tool's own route. Fill `:id` / `{id}`
    placeholders from `params`: values are URL-encoded, and `.` or `..` are rejected, so agent input can't redirect the call.

**What it returns**

| Return value               | Result                                                                                              |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| a string or any JSON value | Sent to the agent (objects also as `structuredContent`). Still an error result if the route failed. |
| a full `ToolResult`        | Used as is, so you control `isError` (e.g. `{ ...ctx.defaultResult(), content: [...] }`).           |
| `undefined`                | The default result.                                                                                 |
| a thrown error             | An error result with the message, which the agent sees.                                             |

`maxResponseChars` still applies to whatever the hook returns.

**For every tool at once**, set `transformResponse` in the server options, e.g. to strip fields everywhere:

```ts
mountMcp(app, {
  name: 'shop-api',
  transformResponse: (res) => (res.ok ? omitDeep(res.json, ['internalNotes', 'passwordHash']) : undefined),
});
```

A tool's own `transformResponse` replaces the server-level one.

## Configuration reference

### Server options (all adapters)

| Option              | Type                  | Default                                                    | Description                                                                                                                        |
| ------------------- | --------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `name`              | `string`              | required                                                   | Server name shown to clients.                                                                                                      |
| `version`           | `string`              | `'1.0.0'`                                                  | Server version.                                                                                                                    |
| `instructions`      | `string`              | none                                                       | Guidance for the model on how to use the tools together.                                                                           |
| `path`              | `string`              | `'/mcp'`                                                   | Endpoint path.                                                                                                                     |
| `allowedOrigins`    | `string[] \| '*'`     | `[]`                                                       | Browser origins allowed to call the endpoint. Requests without `Origin` (CLIs, IDEs, servers) are always allowed.                  |
| `forwardHeaders`    | `string[]`            | `['authorization','cookie','x-api-key','accept-language']` | Headers copied from the MCP request to the internal API call.                                                                      |
| `maxResponseChars`  | `number`              | `100000`                                                   | Longer API responses are truncated before reaching the model.                                                                      |
| `oauth`             | `OAuthOptions`        | none                                                       | Protect the endpoint with OAuth 2.1. See [OAuth](#oauth-sign-in-from-ai-clients).                                                  |
| `transformResponse` | `ResponseTransform`   | none                                                       | Default response hook for route tools without their own. See [Shaping responses](#shaping-and-enriching-responses).                |
| `tools`             | `McpToolDefinition[]` | `[]`                                                       | Extra hand-written tools.                                                                                                          |
| `baseUrl`           | `string`              | loopback                                                   | _Express/Koa/Nest/Adonis._ Where internal calls go. Set it for HTTPS with self-signed certs, unix sockets, or a separate API host. |
| `routes`            | `{method,path,...}[]` | `[]`                                                       | _Express/Koa/Hono._ Expose routes without editing them.                                                                            |
| `routers`           | see guide             | none                                                       | _Express:_ `{ '/prefix': router }`. _Koa:_ `[router]`.                                                                             |
| `middleware`        | `Middleware[]`        | `[]`                                                       | _Express._ Middleware in front of `/mcp`, such as auth.                                                                            |
| `guards`            | `CanActivate[]`       | `[]`                                                       | _NestJS._ Guards on the MCP controller.                                                                                            |
| `decorators`        | `ClassDecorator[]`    | `[]`                                                       | _NestJS._ Extra decorators on the MCP controller, e.g. `[Public()]` for global guards.                                             |
| `pathPrefix`        | `string`              | none                                                       | _NestJS._ Extra prefix for tool routes. Global prefix and URI versioning are automatic.                                            |
| `routeOptions`      | `object`              | none                                                       | _Fastify._ Extra route options for `/mcp`, such as `onRequest` hooks.                                                              |
| `configureRoute`    | `(route) => void`     | none                                                       | _AdonisJS._ Configure the MCP route, e.g. add middleware.                                                                          |

### Tool options (`@McpTool()`, `mcpTool()`, `config.mcp`, `.mcp()`)

| Option                                | Description                                                                                                                                                         |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                                | Tool name (`[A-Za-z0-9_-]`, max 64). Default is derived from the route, e.g. `get_users_by_id`. Agents call tools by name, so set it explicitly and keep it stable. |
| `description`                         | **The most important field.** Tells the model what the tool does and when to use it.                                                                                |
| `title`                               | Human-friendly display name.                                                                                                                                        |
| `input` / `params` / `query` / `body` | Schemas, see [above](#defining-tool-inputs-schemas).                                                                                                                |
| `annotations`                         | MCP hints: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Defaults come from the HTTP method (GET → read-only, DELETE → destructive).        |
| `headers`                             | Static headers added to the internal request.                                                                                                                       |
| `scopes`                              | OAuth scopes the token needs to call this tool (with the `oauth` option). Missing scopes answer `403 insufficient_scope`.                                           |
| `transformResponse`                   | Shape or enrich what the agent sees from this route's response. See [Shaping and enriching responses](#shaping-and-enriching-responses).                            |

Each internal request also carries `X-Mcp-Tool: <tool name>`, so you can log or meter agent traffic separately.

## Custom (non-HTTP) tools

Not everything needs a route:

```ts
import { defineTool } from 'mcp-expose';
import { z } from 'zod';

const convert = defineTool({
  name: 'convert_currency',
  description: "Convert an amount between currencies using today's rate.",
  input: z.object({ amount: z.number(), from: z.string().length(3), to: z.string().length(3) }),
  handler: async ({ amount, from, to }, ctx) => ({ result: await fx.convert(amount, from, to) }),
});

mountMcp(app, { name: 'shop-api', tools: [convert] });
```

Handlers can return a string, any JSON value, or a full MCP `ToolResult`. `ctx.headers` holds the MCP request's headers, so you can authenticate there too. With the `oauth` option, `ctx.auth` holds the verified token (`subject`, `scopes`, `clientId`, `claims`).

## Security checklist

- **Opt-in only.** Nothing is exposed unless you mark it. Review marked routes the way you review a public API, because an LLM can call them with any arguments.
- **Use per-user credentials.** Clients send their own token, the token is forwarded, and your guards authorise the call. Avoid one shared super-token. For remote servers, prefer the [`oauth` option](#oauth-sign-in-from-ai-clients): users sign in through your identity provider, tokens are audience-checked, and sensitive tools can require extra `scopes`.
- **Protect discovery too** if tool names are sensitive (`guards`, `middleware`, `routeOptions`).
- **Rate limits and IPs:** the agent IP is sent as `X-Forwarded-For`. For loopback adapters, trust loopback only: Express `app.set('trust proxy', 'loopback')`, Koa `app.proxy = true` behind a proxy that overwrites the header, Nest (Express) `app.set('trust proxy', 'loopback')`. Fastify `inject()` sets the IP directly.
- **Browser access:** keep `allowedOrigins` empty unless a browser app must call `/mcp` directly.
- **Annotations are hints, not security.** A `readOnlyHint` never prevents a call. Enforce permissions in your API.
- **Destructive actions:** clients may use `destructiveHint` to decide when to ask the user for confirmation. Keep it accurate, and require stronger auth for dangerous routes.
- **Response size:** tune `maxResponseChars`, and prefer endpoints that paginate.

## Writing tools agents use well

- Describe **when** to use the tool, not only what it does: _"Search products by name. Use this before `create_order` to find a valid `productId`."_
- Document units, formats and limits in schema `description`s (`"price in cents"`, `"ISO 8601 date"`).
- Prefer a few task-shaped tools (`search_orders`) over many CRUD primitives.
- Return clear 4xx messages. They go straight to the model, which uses them to retry correctly.
- Use `instructions` on the server for cross-tool workflow hints.

## Limitations and roadmap

Current scope (1.x):

- Stateless Streamable HTTP with JSON responses. No server-initiated SSE stream or sessions. The spec allows this, and it keeps the server horizontally scalable.
- Tools only. `resources/list` and `prompts/list` return empty lists.
- NestJS: URI versioning is detected. Header and media-type versioning need `headers` on the tool.
- Express 5 routers mounted with a path must be listed in `routers`.
- Path parameters cannot be empty, `.` or `..`. These are rejected so an agent cannot reach routes that were not exposed.

Planned (non-breaking, 1.x minor releases):

- Per-user tool lists (hide tools the token's scopes can't call)
- Next.js route handlers, Hapi and Elysia adapters
- Structured output schemas (`outputSchema`), and binary/file responses
- Streaming long-running responses over SSE
- CLI to preview generated tools (`npx mcp-expose inspect`)
- Resources from GET routes, and prompts

Contributions are welcome. See [Development](#development).

## Development

```bash
npm install
npm test            # vitest: core + all six adapters (real servers, real HTTP)
npm run test:e2e    # pack → install into 19 fresh framework projects → official MCP SDK client (with and without OAuth)
npm run typecheck
npm run build       # ESM + CJS + .d.ts into dist/

# run an example (after npm run build)
npm run example:express     # or example:fastify / example:koa / example:hono / example:nestjs
```

Project layout:

```
src/
  core/        framework-agnostic: MCP JSON-RPC server, route→tool mapping, schemas, dispatchers
  express/     mcpTool() + mountMcp()
  nestjs/      @McpTool() + McpModule + DTO → JSON Schema
  fastify/     fastifyMcp plugin (config.mcp)
  koa/         mcpTool() + mountMcp() / koaMcp() (+ Koa 1 legacy helpers)
  hono/        mcpTool() + mountMcp()
  adonisjs/    .mcp() route macro + mountMcp()
examples/      runnable apps for every framework + an OpenAPI gateway
e2e/           developer-style end-to-end tests (one project per framework/version)
test/          one shared behavioural contract, verified against every adapter
```

Adding an adapter: find the marked routes, then call `createRouteTool(route, options, dispatcher)` and
`server.handleHttp()`. Reuse `test/helpers.ts#assertAdapterContract` to test it.

## Versioning and support

mcp-expose follows [Semantic Versioning](https://semver.org). Older major versions keep receiving fixes on
maintenance branches, published under `latest-<major>` dist-tags (for example `npm i mcp-expose@latest-3`),
so a fix to an old major never changes what `npm i mcp-expose` installs. See the
[support policy](SECURITY.md#supported-versions) and [RELEASING.md](RELEASING.md).

## Contributing

Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) and the
[Code of Conduct](CODE_OF_CONDUCT.md). Report security issues privately as described in [SECURITY.md](SECURITY.md).
Release notes are in [CHANGELOG.md](CHANGELOG.md).

## License

[MIT](LICENSE) © Nitin Kachhadiya

Maintenance

ActivityMaintained
ResponsivenessNo issues