nestjs-mcp-controller
v0.1.0
Published
Build Model Context Protocol (MCP) servers in NestJS with @Controller-style classes, @Tool() methods, middleware, guards and OAuth.
Maintainers
Readme
nestjs-mcp-controller
Build Model Context Protocol (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.
@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
McpAuthServerModulefor standalone deployments.
- Resource server (default): plug in a
- Per-tool authorization with
@RequireScopes()/@RequireRoles()— unauthorized primitives are hidden fromlistand rejected on call.
Install
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 rxjsQuick start
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:
@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
@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.
@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:
// 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.
@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.
@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:
@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/ — 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
pnpm install
pnpm build # tsc → dist/
pnpm test # jest e2e: real MCP client over HTTP
pnpm lintLicense
MIT
