@markygab/nest-mcp
by markygab
README.md
# @markygab/nest-mcp
Internal NestJS primitives for defining and serving Model Context Protocol (MCP)
tools. The package provides decorator-based tool discovery, TypeBox/AJV input
and output validation, explicit parameter injection, and MCP SDK server
construction.
It deliberately does not include HTTP routing, authentication, authorization,
tenant policy, or application-specific context. Each host application supplies
those concerns at its own boundary.
## Install and register
Add the workspace package as a dependency, then import `NestMcpModule` once in
the application's root module or a module visible to all MCP tool providers.
```ts
import { Module } from "@nestjs/common";
import { NestMcpModule } from "@markygab/nest-mcp";
import { ProjectsModule } from "./projects/projects.module.js";
@Module({
imports: [NestMcpModule, ProjectsModule],
})
export class AppModule {}
```
The package uses Nest's `DiscoveryService` to find decorated providers across
the application container. Tool classes must still be registered as normal
Nest providers in their feature modules.
## Define a tool
Use TypeBox schemas for inputs and, where practical, outputs. `@McpTools()`
assigns the provider to one named MCP server. `@McpTool()` declares a single
tool, while `@McpArgs()` and `@McpContext()` make handler injection explicit.
```ts
import { Inject, Injectable } from "@nestjs/common";
import {
McpArgs,
McpContext,
McpTool,
McpTools,
type McpRequestContext,
type McpToolArgs,
} from "@markygab/nest-mcp";
import { Type } from "@sinclair/typebox";
import { ProjectsQueryService } from "./projects-query.service.js";
const GetProjectInput = Type.Object({
id: Type.String({ minLength: 1 }),
});
const GetProjectOutput = Type.Object({
project: Type.Union([
Type.Object({ id: Type.String(), name: Type.String() }),
Type.Null(),
]),
});
type GetProjectInput = McpToolArgs<typeof GetProjectInput>;
@Injectable()
@McpTools("projects_mcp")
export class ProjectsMcp {
constructor(
@Inject(ProjectsQueryService)
private readonly projects: ProjectsQueryService,
) {}
@McpTool({
name: "projects_get",
title: "Get project",
description: "Get a project visible to the current caller.",
inputSchema: GetProjectInput,
outputSchema: GetProjectOutput,
})
async getProject(
@McpArgs() input: GetProjectInput,
@McpContext() context: McpRequestContext,
) {
return {
project: await this.projects.getVisibleTo(context.actorId, input.id),
};
}
}
```
Tool names must be unique within a named server. Keep handlers thin: enforce
application policy and call existing domain services rather than placing
vendor-client or persistence logic in MCP classes.
## Tool schemas
`@McpTool()` accepts TypeBox schemas and ordinary JSON Schema for both input
and output. TypeBox remains a convenient authoring option and
`McpToolArgs<typeof schema>` continues to infer its handler type. For plain
JSON Schema—such as a schema derived from an OpenAPI document—supply your own
generated or application type for the handler argument; arbitrary JSON Schema
is not inferred as TypeScript.
```ts
const CreateIssueInputSchema = {
type: "object",
properties: {
priority: { type: "string", enum: ["low", "high"] },
title: { type: "string" },
},
required: ["priority", "title"],
} as const;
type CreateIssueInput = {
priority: "low" | "high";
title: string;
};
@McpTool({
name: "issues_create",
description: "Create an issue",
inputSchema: CreateIssueInputSchema,
})
createIssue(@McpArgs() input: CreateIssueInput) {
return this.issues.create(input);
}
```
## Guard tool invocations
`@UseMcpGuards()` attaches policy-agnostic guards to a tools class or an
individual tool. Guards run after the input schema is validated and before the
handler. Class guards run first, followed by method guards. Return `false` or
throw to prevent invocation; the caller receives the normal MCP tool error
response. Pass either a guard instance or an injectable Nest provider class;
provider classes are resolved from the application container.
```ts
import { Injectable } from "@nestjs/common";
import {
McpArgs,
McpTool,
McpTools,
UseMcpGuards,
type McpExecutionContext,
type McpGuard,
} from "@markygab/nest-mcp";
const auditGuard: McpGuard = {
canActivate(context: McpExecutionContext) {
// validatedArgs, serverName, toolName, requestContext, and toolOptions
// are available here. Application policy remains outside this library.
return true;
},
};
@Injectable()
class ProjectsAccessGuard implements McpGuard {
canActivate(context: McpExecutionContext) {
return Boolean(context.requestContext.actorId);
}
}
@McpTools("projects_mcp")
@UseMcpGuards(auditGuard, ProjectsAccessGuard)
class ProjectsMcp {
@McpTool({ name: "projects_get", description: "Get a project", inputSchema: GetProjectInput })
@UseMcpGuards({ canActivate: () => true })
getProject(@McpArgs() input: GetProjectInput) {
return { project: input.id };
}
}
```
## Intercept tool invocations
`@UseMcpInterceptors()` wraps a permitted tool invocation. It accepts
`McpInterceptor` objects or injectable Nest provider classes and may be applied
to a tools class or a single tool. Provider classes are resolved from the
application container.
Interceptors receive the same `McpExecutionContext` as guards, including the
validated arguments and trusted request context. Class interceptors enter in
declaration order, then method interceptors; they unwind in reverse order.
Thrown errors propagate to the standard MCP tool error response.
```ts
import { Injectable } from "@nestjs/common";
import {
UseMcpInterceptors,
type McpExecutionContext,
type McpInterceptor,
} from "@markygab/nest-mcp";
@Injectable()
class TimingInterceptor implements McpInterceptor {
async intercept(context: McpExecutionContext, next) {
const startedAt = Date.now();
try {
return await next();
} finally {
// Hosts decide whether and where to record this information.
console.log(context.toolName, Date.now() - startedAt);
}
}
}
@UseMcpInterceptors(TimingInterceptor)
class ProjectsMcp {
// @UseMcpInterceptors(...) can also be applied to an @McpTool() method.
}
```
## Request context and policy
`McpRequestContext` contains common request identifiers, optional actor and
tenant fields, an optional `integration` object, and `allowedToolNames`.
Applications can add their own fields when creating the context.
When a request context contains `allowedToolNames`, the package hides every
other tool from both `tools/list` and `tools/call`. Authentication and
authorization should run before the MCP request reaches the package; build the
trusted context from the verified principal and route parameters, never from
model-provided tool arguments.
## Serve over HTTP
The host owns the controller, authentication, and request-context mapping. Add
a controller and transport service to the application that imports
`NestMcpModule`; the example below exposes `POST`, `GET`, and `DELETE` requests
at `/mcp/projects_mcp`, as required by Streamable HTTP clients.
```ts
import {
All,
Controller,
Inject,
Injectable,
Module,
Param,
Req,
Res,
} from "@nestjs/common";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { McpServerService, type McpRequestContext } from "@markygab/nest-mcp";
import type { Request, Response } from "express";
@Injectable()
export class ProjectsMcpHttpService {
constructor(
@Inject(McpServerService)
private readonly mcpServer: McpServerService,
) {}
async handle(
serverName: string,
request: Request,
response: Response,
context: McpRequestContext,
) {
const transport = new StreamableHTTPServerTransport({
// Use stateless requests when the host does not persist MCP sessions.
sessionIdGenerator: undefined,
});
const server = this.mcpServer.createServer(serverName, context);
await server.connect(transport);
try {
await transport.handleRequest(request, response, request.body);
} finally {
await transport.close();
}
}
}
@Controller("mcp")
export class ProjectsMcpHttpController {
constructor(
@Inject(ProjectsMcpAuthService)
private readonly auth: ProjectsMcpAuthService,
@Inject(ProjectsMcpHttpService)
private readonly http: ProjectsMcpHttpService,
) {}
@All(":serverName")
async handle(
@Param("serverName") serverName: string,
@Req() request: Request,
@Res() response: Response,
) {
const context = this.auth.verifyAuthorization(
request.header("authorization"),
serverName,
);
await this.http.handle(serverName, request, response, context);
}
}
```
Register these host-side providers alongside the shared module:
```ts
@Module({
imports: [NestMcpModule],
controllers: [ProjectsMcpHttpController],
providers: [ProjectsMcpAuthService, ProjectsMcpHttpService],
})
export class ProjectsMcpModule {}
```
`ProjectsMcpAuthService` should verify the bearer token and return only trusted
context, for example `{ requestId, actorId, tenantId, allowedToolNames }`.
Bind the token to `serverName` so a credential issued for `projects_mcp` cannot
be reused against another named server. If you use stateful MCP sessions,
replace the stateless transport configuration with a host-managed session ID
strategy and retain the corresponding transport for later requests.
The host application owns the corresponding controller, authentication
service, and transport service.
## Runtime limits
Set these optional environment variables in the host process to bound tool
execution and serialized responses:
- `NEST_MCP_TOOL_TIMEOUT_MS` defaults to `15000`.
- `NEST_MCP_TOOL_RESPONSE_MAX_BYTES` defaults to `128000`.
Both values must be positive integers.
## Serve over stdio
For a local process, retrieve `McpServerService` from the Nest application
context and provide the named server explicitly:
```ts
const app = await NestFactory.createApplicationContext(AppModule);
await app.get(McpServerService).startStdio("projects_mcp");
```
## Exports
- `NestMcpModule` - registers discovery, validation, invocation, and server
providers.
- `McpTools`, `McpTool`, `McpArgs`, `McpContext` - tool-definition decorators.
- `McpServerService` - creates MCP SDK server instances, lists tools, and calls
tools programmatically.
- `McpRequestContext`, `McpToolArgs`, `McpToolOptions`, and tool metadata types.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues