Guide · TypeScript
TypeScript decorators for MCP servers
Decorators let you annotate class methods with tool metadata — name, description, input schema — and have a framework automatically register them with the MCP server. This pattern is common in frameworks like NestJS and FastAPI (Python); applying it to MCP servers trades explicitness for declarative ergonomics, which works well when a server has many tools that follow the same structure.
TL;DR
TypeScript has two decorator systems: the experimental legacy decorators (experimentalDecorators: true, uses emitDecoratorMetadata) and the Stage 3 standard decorators (no flag needed in TypeScript 5.0+). Use Stage 3 decorators for new code — they're standardized, don't require reflect-metadata, and produce spec-compliant JS. The decorator pattern for MCP tools works as: a class decorator scans method decorators at class definition time, collects tool metadata (name, description, schema), and registers handlers on an McpServer instance. Method decorators for cross-cutting concerns (logging, timing, auth) are the same whether you use decorators for registration or functional-style server.tool(). Wire AliveMCP to monitor the deployed result — decorator-based registration has the same failure modes as functional registration.
Stage 3 decorators vs experimental decorators
The decorator ecosystem has two generations:
| Feature | Experimental (experimentalDecorators) | Stage 3 (TypeScript 5.0+) |
|---|---|---|
| Flag required | "experimentalDecorators": true in tsconfig | None — on by default in TS 5.0+ |
| Metadata reflection | Via reflect-metadata polyfill + emitDecoratorMetadata | Via TC39 Decorator Metadata proposal (separate polyfill or native) |
| Decorator runs on | Class, method, accessor, property, parameter | Class, method, accessor, getter/setter (not parameters) |
| Ecosystem compatibility | NestJS, TypeORM, class-validator — most existing decorator libs | Newer libs; NestJS is migrating |
| Specification status | Non-standard TypeScript extension | TC39 Stage 3 — shipping in V8 |
For MCP servers, you don't need parameter decorators or reflect-metadata — the input schema is passed explicitly via the decorator, not inferred from TypeScript types. This means Stage 3 decorators work cleanly without a polyfill.
Building a decorator-based MCP server
The pattern: a @Tool method decorator stores metadata in a per-class registry; a register(instance, server) function reads the registry and calls server.tool() for each decorated method.
// tool-decorator.ts — Stage 3 decorator implementation
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
type ZodShape = Record<string, z.ZodTypeAny>;
interface ToolMeta {
name: string;
description: string;
schema: ZodShape;
methodKey: string;
}
// Per-class metadata store (keyed by constructor)
const toolRegistry = new WeakMap<object, ToolMeta[]>();
// Stage 3 method decorator — no experimental flag needed
export function Tool(name: string, description: string, schema: ZodShape) {
return function (
target: unknown, // class prototype
context: ClassMethodDecoratorContext
) {
const methodKey = String(context.name);
// Store metadata on the class prototype
const proto = target as object;
const existing = toolRegistry.get(proto) ?? [];
existing.push({ name, description, schema, methodKey });
toolRegistry.set(proto, existing);
};
}
// Register all decorated methods as MCP tools
export function registerTools(instance: object, server: McpServer): void {
const proto = Object.getPrototypeOf(instance) as object;
const tools = toolRegistry.get(proto) ?? [];
for (const meta of tools) {
const handler = (instance as Record<string, unknown>)[meta.methodKey];
if (typeof handler !== "function") continue;
server.tool(
meta.name,
meta.description,
z.object(meta.schema),
// Bind instance so `this` works inside decorated methods
handler.bind(instance) as (args: Record<string, unknown>) => Promise<CallToolResult>
);
}
}
// tools/search-tools.ts — decorated tool class
import { z } from "zod";
import { Tool } from "../tool-decorator.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
export class SearchTools {
constructor(private readonly db: Database) {}
@Tool("search_users", "Search users by name or email", {
query: z.string().min(1).max(200),
limit: z.number().int().min(1).max(50).default(10),
})
async searchUsers({ query, limit }: { query: string; limit: number }): Promise<CallToolResult> {
const results = await this.db.users.search(query, limit);
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
@Tool("search_orgs", "Search organizations by name", {
query: z.string().min(1),
activeOnly: z.boolean().default(true),
})
async searchOrgs({ query, activeOnly }: { query: string; activeOnly: boolean }): Promise<CallToolResult> {
const results = await this.db.orgs.search(query, { activeOnly });
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
}
// server.ts — assembly
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SearchTools } from "./tools/search-tools.js";
import { registerTools } from "./tool-decorator.js";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
const db = new Database(process.env.DATABASE_URL!);
const searchTools = new SearchTools(db);
registerTools(searchTools, server); // registers search_users + search_orgs
await server.connect(new StdioServerTransport());
Method decorators for cross-cutting concerns
Once you're in decorator territory, cross-cutting concerns like logging, timing, and auth checks become method decorators applied to tool handlers:
// Timing decorator — wraps any async method to log execution time
export function Timed(label?: string) {
return function (
target: unknown,
context: ClassMethodDecoratorContext
) {
const methodName = label ?? String(context.name);
return async function (this: unknown, ...args: unknown[]) {
const start = performance.now();
try {
const result = await (target as Function).apply(this, args);
const ms = (performance.now() - start).toFixed(1);
console.log(`[${methodName}] ${ms}ms`);
return result;
} catch (err) {
const ms = (performance.now() - start).toFixed(1);
console.error(`[${methodName}] ${ms}ms ERROR`, err);
throw err;
}
};
};
}
// Auth decorator — checks for a userId in the handler context
// (Requires your auth layer to inject context before the MCP tool call)
export function RequiresAuth() {
return function (
target: unknown,
context: ClassMethodDecoratorContext
) {
return async function (this: unknown, args: Record<string, unknown>) {
if (!args.__userId) {
return { isError: true, content: [{ type: "text", text: "Authentication required" }] };
}
return (target as Function).apply(this, [args]);
};
};
}
// Apply multiple decorators — executed bottom-up (closest to method runs first)
export class AdminTools {
@Tool("delete_user", "Delete a user account", { userId: z.string().uuid() })
@RequiresAuth()
@Timed("delete_user")
async deleteUser({ userId, __userId }: { userId: string; __userId: string }) {
await this.db.users.delete(userId, { deletedBy: __userId });
return { content: [{ type: "text", text: `Deleted user ${userId}` }] };
}
}
Note that decorator execution order in Stage 3 is bottom-up when stacked — the decorator closest to the method runs first. In the example above: @Timed wraps the result of @RequiresAuth, which wraps the original deleteUser method.
Decorator pattern vs functional registration: when to use each
| Factor | Decorator pattern | Functional (server.tool()) |
|---|---|---|
| Readability for many tools | Better — metadata co-located with handler | Verbose at scale — names, schemas, handlers spread across file |
| Readability for few tools | Boilerplate overhead visible | Better — simpler, no framework to understand |
| Testability | Good — inject dependencies via constructor; test class methods directly | Good — test handler functions directly without class |
| Cross-cutting concerns | Excellent — decorators compose cleanly | Requires explicit wrapping at each call site |
| TypeScript compatibility | Requires TS 5.0+ or experimentalDecorators | Works in any TypeScript version |
| Debugging | Stack traces include decorator wrappers | Direct stack traces to handler |
Use decorators when: you have 10+ tools with consistent structure, multiple cross-cutting concerns, and a team familiar with decorator patterns. Use functional registration when: you have fewer than 10 tools, you value simplicity over elegance, or your team isn't familiar with decorators.
Legacy experimental decorators compatibility
If you're using NestJS, class-validator, or other decorator libraries that require experimental decorators, you can mix systems carefully. The toolRegistry pattern above works with experimental decorators by changing the decorator signature:
// Legacy experimental decorator signature — requires tsconfig experimentalDecorators: true
export function Tool(name: string, description: string, schema: ZodShape) {
return function (
target: object, // class prototype (not unknown)
propertyKey: string, // method name
_descriptor: PropertyDescriptor
) {
const existing = toolRegistry.get(target) ?? [];
existing.push({ name, description, schema, methodKey: propertyKey });
toolRegistry.set(target, existing);
};
}
// registerTools() is identical to Stage 3 version above
Don't mix Stage 3 and experimental decorators in the same file. The two systems have incompatible metadata storage and different TypeScript compiler behavior. If you're migrating from experimental to Stage 3, update all decorators in a single pass.
Monitoring decorator-registered MCP servers
Decorator-based tool registration has the same production failure modes as functional registration: a decorator that silently fails to register a tool (e.g., a bug in the registry lookup or a WeakMap key mismatch) produces a server that starts without error but is missing tools at runtime. The only way to catch this from outside is to probe the tools/list endpoint.
AliveMCP monitors your server's tools/list response and alerts when the tool count or schema hash changes unexpectedly — catching silent registration failures, decorator bugs, and deployment issues where the wrong version of the server is running in production.
# Verify tool registration after deploy
curl -sX POST https://your-mcp-server.com/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"probe","version":"1"}}}' \
| jq '.result.capabilities.tools'
# Should be {} or {"listChanged": true} — confirms tools capability is advertised
Frequently asked questions
Can I use class decorators instead of method decorators to register all tools in a class at once?
Yes, but you still need method-level metadata. The typical pattern: method decorators store metadata (name, description, schema) on the class prototype at decoration time; the class decorator (or a separate registerTools call) reads that metadata and calls server.tool() for each registered method. The class decorator is the trigger, the method decorators are the data source. This avoids the class decorator needing to enumerate all methods — it only registers the ones explicitly decorated.
How do I pass the McpServer instance into the decorator registration?
Decorators run at class definition time (module load), not at instantiation time. You can't pass an McpServer instance into a decorator directly. The pattern is: decorators store metadata; a separate registerTools(toolsInstance, server) call at application startup reads the metadata and registers handlers. This means registration is deferred until you have both the class instance (with injected dependencies) and the server — which is the correct time to wire them together.
Do decorator-registered tools show up in tools/list the same as functionally-registered tools?
Yes — from the MCP protocol's perspective, all tools are identical regardless of how they were registered. The decorator pattern is a code organization pattern; it ultimately calls server.tool() with the same arguments a functional registration would. The tools/list response, the JSON-RPC dispatch, and the monitoring behavior are identical.
Can I use Zod inference for handler parameter types in a decorator pattern?
Yes. If you define the Zod schema as a constant and pass it to @Tool, you can use z.infer<typeof MySchema> for the handler parameter type: const schema = z.object({ id: z.string().uuid() }); type MyArgs = z.infer<typeof schema>; async handleTool(args: MyArgs) { ... }. The schema is passed to the decorator for registration, and the same type is used for the handler signature — keeping input schema and handler type in sync.
What's the performance impact of decorator-based registration?
Zero at runtime — registration happens once at startup when registerTools() is called. After that, tool dispatch is identical to functional registration: a Map lookup by tool name, Zod validation, handler call, response serialization. The decorator pattern has no per-request overhead compared to functional-style server.tool() registration.
Further reading
- MCP TypeScript SDK internals — how tool registration and dispatch work
- MCP server dependency injection — constructor injection for testable tools
- Building MCP servers in TypeScript — getting started guide
- MCP server tool design — names, descriptions, and argument shapes
- MCP server middleware — request interceptors and cross-cutting concerns
- MCP server testing — unit, integration, and protocol-layer tests
- AliveMCP — uptime and schema monitoring for MCP servers