Skip to main content
Glama
jaenster

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