nestjs-mcp-controller
by jaenster
README.md
# nestjs-mcp-controller
Build [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers inside a NestJS application using ordinary controllers. Declare tools, resources and prompts as decorated methods — the library discovers them, wires them to an MCP server over Streamable HTTP, and gives you NestJS-native **middleware**, **guards** and **OAuth** on top.
It is designed to be **embedded** in an existing platform: the platform keeps its own authentication and simply tells the library how to validate a bearer token. Per-tool scopes and roles mean a logged-in user only ever sees — and can only call — the tools they are allowed to.
```ts
@Controller()
export class MathController {
@Tool('add_numbers', { inputSchema: { a: z.number(), b: z.number() } })
add({ a, b }: { a: number; b: number }) {
return `${a + b}`;
}
}
```
## Features
- **`@Tool()`, `@Resource()`, `@Prompt()`** on any `@Controller()` / `@Injectable()` — auto-discovered across the app.
- Built on the official **`@modelcontextprotocol/sdk`** (spec-compliant, tracks upstream).
- **Zod** schemas for input/output — validated before your method runs.
- **Streamable HTTP** transport with stateful sessions (or stateless mode).
- **Middleware** pipeline (`McpMiddleware`) — global, per-controller or per-method; the MCP analogue of interceptors.
- **OAuth, layered**:
- *Resource server* (default): plug in a `McpTokenVerifier`; the library validates bearer tokens and serves RFC 9728 protected-resource metadata.
- *Authorization server* (optional): mount the SDK's OAuth endpoints with `McpAuthServerModule` for standalone deployments.
- **Per-tool authorization** with `@RequireScopes()` / `@RequireRoles()` — unauthorized primitives are hidden from `list` and rejected on call.
## Install
```bash
pnpm add nestjs-mcp-controller @modelcontextprotocol/sdk zod
# peer deps (you already have these in a Nest app)
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs
```
## Quick start
```ts
import { Module, Controller } from '@nestjs/common';
import { McpModule, Tool, z } from 'nestjs-mcp-controller';
@Controller()
export class MathController {
@Tool('add_numbers', {
description: 'Add two numbers',
inputSchema: { a: z.number(), b: z.number() },
})
add({ a, b }: { a: number; b: number }) {
return `${a + b}`; // string | object | a full CallToolResult — all accepted
}
}
@Module({
imports: [McpModule.forRoot({ server: { name: 'my-mcp', version: '1.0.0' } })],
controllers: [MathController],
})
export class AppModule {}
```
The MCP endpoint is served at **`POST/GET/DELETE /mcp`** (configurable via `path`). Point any MCP client (Claude Desktop, the MCP Inspector, the SDK client) at it.
## Handler return values
A `@Tool` method may return:
- a `string` → wrapped as a single text block,
- any object → JSON-stringified into a text block **and** attached as `structuredContent`,
- a full `CallToolResult` (`{ content: [...] }`) → passed through untouched.
Every handler also receives an `McpContext` as its last argument:
```ts
@Tool('whoami')
whoami(_args: unknown, ctx: McpContext) {
return { clientId: ctx.authInfo?.clientId, scopes: ctx.scopes, session: ctx.sessionId };
}
```
`ctx` carries `authInfo`, `scopes`, `sessionId` and the raw SDK `extra` (abort `signal`, `sendNotification` for progress, …).
## Resources & prompts
```ts
@Resource({ uri: 'config://app', mimeType: 'application/json' })
appConfig(uri: URL) {
return { contents: [{ uri: uri.href, text: JSON.stringify(this.config) }] };
}
// templated URIs work too: receive (uri, variables, ctx)
@Resource({ uri: 'users://{id}' })
user(uri: URL, { id }: { id: string }) {
return { contents: [{ uri: uri.href, text: JSON.stringify(this.find(id)) }] };
}
@Prompt({ name: 'review_code', argsSchema: { code: z.string() } })
review({ code }: { code: string }) {
return { messages: [{ role: 'user', content: { type: 'text', text: `Review:\n${code}` } }] };
}
```
## Middleware
Around-style middleware — log, time, rate-limit, mutate args or shape results. Resolved from DI, so it can inject services.
```ts
@Injectable()
export class LoggingMiddleware implements McpMiddleware {
async use(ctx: McpContext, next: McpNext) {
const start = Date.now();
const result = await next();
console.log(`${ctx.kind} ${ctx.name} in ${Date.now() - start}ms`);
return result;
}
}
```
Apply it globally, per-controller or per-method:
```ts
// global
McpModule.forRoot({ server, middleware: [LoggingMiddleware] });
// per controller / method (outermost = global → class → method)
@UseMcpMiddleware(LoggingMiddleware)
@Controller()
class BillingController {
@Tool('refund') @UseMcpMiddleware(RateLimitMiddleware) refund() { /* ... */ }
}
```
> Register middleware that needs singleton services as providers in a module visible to `McpModule`; otherwise it is instantiated transiently per call.
## Authentication & authorization
### Resource server (recommended for embedding)
Implement a `McpTokenVerifier` that turns a bearer token into `McpAuthInfo`. The platform keeps its own OAuth/login; this is just the validation seam.
```ts
@Injectable()
export class JwtVerifier implements McpTokenVerifier {
async verify(token: string, req: Request): Promise<McpAuthInfo | null> {
const claims = await verifyJwt(token); // e.g. with `jose`
if (!claims) return null;
return {
token,
clientId: claims.sub,
scopes: claims.scope?.split(' ') ?? [],
expiresAt: claims.exp,
extra: { roles: claims.roles, tenantId: claims.tenant }, // your identity
};
}
}
McpModule.forRoot({
server: { name: 'my-mcp', version: '1.0.0' },
auth: {
enabled: true,
requireAuth: true, // 401 + RFC 9728 challenge when invalid/absent
verifier: JwtVerifier, // instance or class (DI-resolved)
resourceUrl: 'https://api.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
},
});
```
With `resourceUrl` set, the library serves `GET /.well-known/oauth-protected-resource` so clients can discover the authorization server.
Set `requireAuth: false` to allow anonymous access while still enforcing per-tool scope/role checks (anonymous callers simply don't pass them).
### Per-tool authorization
`@RequireScopes()` / `@RequireRoles()` apply to a method or an entire controller (class-level requirements are inherited and merged). A caller missing the requirement **won't see the tool in `list`** and **can't call it**.
```ts
@Controller()
@RequireScopes('billing:read') // applies to every tool in the class
export class BillingController {
@Tool('list_invoices') list() { /* needs billing:read */ }
@Tool('refund_invoice', { inputSchema: { id: z.string() } })
@RequireScopes('billing:write') // additionally needs billing:write
@RequireRoles('admin') // and the admin role
refund({ id }: { id: string }) { /* ... */ }
}
```
Roles are read from `authInfo.extra.roles` by default; override with `auth.rolesResolver`.
> Because a session is bound to one identity, tools are filtered when the session is created **and** re-checked on every call.
### Authorization server (optional, standalone)
When you are *not* embedding behind an existing OAuth server, mount the SDK's authorization endpoints:
```ts
@Module({
imports: [
McpAuthServerModule.forRoot({ provider, issuerUrl: new URL('https://auth.example.com') }),
McpModule.forRoot({ /* ... */ }),
],
})
export class AppModule {}
```
## Configuration
`McpModule.forRoot(options)` / `McpModule.forRootAsync(asyncOptions)`:
| Option | Default | Description |
|-|-|-|
| `server` | — | `{ name, version, instructions? }` reported on `initialize` |
| `path` | `mcp` | HTTP route for the transport (`forRoot` only) |
| `stateful` | `true` | Per-client sessions with `Mcp-Session-Id`; `false` = stateless |
| `middleware` | `[]` | Global middleware classes |
| `auth` | disabled | `{ enabled, requireAuth, verifier, resourceUrl, authorizationServers, rolesResolver }` |
| `allowedHosts` | — | Host allowlist for DNS-rebinding protection |
| `isGlobal` | `false` | Register the module globally |
`forRootAsync` supports `useFactory` / `useClass` / `useExisting` (implementing `McpOptionsFactory`).
## Example
A runnable example lives in [`example/`](./example) — billing tools with scopes, roles, a logging middleware and a verifier. Run it with `npx ts-node example/main.ts` and connect with header `Authorization: Bearer root:billing:read,billing:write|admin`.
## Development
```bash
pnpm install
pnpm build # tsc → dist/
pnpm test # jest e2e: real MCP client over HTTP
pnpm lint
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues