Skip to content

Model Context Protocol (MCP) ​

@tsed/platform-mcp brings Model Context Protocol support to every Ts.ED HTTP adapter. The module exposes a configurable /mcp endpoint, lets you register tools/resources/prompts through DI-aware helpers or decorators, and reuses the same MCP primitives that power the CLI integration.

Need a standalone MCP server?

Import @tsed/platform-mcp/cli to run the same tools, resources, and prompts over stdio or Streamable HTTP. The CLI entry point does not mount a Ts.ED HTTP application.

Installation ​

bash
npm install @tsed/platform-mcp
bash
yarn add @tsed/platform-mcp
bash
pnpm add @tsed/platform-mcp
bash
bun add @tsed/platform-mcp
typescript
import {Configuration} from "@tsed/di";
import "@tsed/platform-express";
import "@tsed/platform-mcp";

@Configuration({
  mcp: {
    path: "/mcp" // defaults to "/mcp"
  }
})
export class Server {}

Register your MCP providers in the mcp configuration so the module can expose them through the HTTP endpoint:

typescript
import {Configuration} from "@tsed/di";
import "@tsed/platform-express";
import "@tsed/platform-mcp";

import {TestPrompt} from "./prompts/TestPrompt.js";
import {TestResource} from "./resources/TestResource.js";
import {TestTool} from "./tools/TestTool.js";

@Configuration({
  mcp: {
    prompts: [TestPrompt],
    resources: [TestResource],
    tools: [TestTool]
  }
})
export class Server {}

Explicit registration required

@Tool, @Prompt, and @Resource do not expose a class automatically. Add each decorated class to the tools, prompts, or resources array of every MCP server that should expose its handlers. This lets multiple MCP servers select different provider classes.

All registration helpers return DI tokens. Add those tokens to a module providers array, or expose them from a feature module. Both the decorators and the function API execute handlers inside a Ts.ED DIContext, so you can reuse your existing providers and services.

Run an MCP server from a CLI ​

The @tsed/platform-mcp/cli entry point creates one MCP server after your CLI has initialized the Ts.ED DI container. It supports two transports:

  • stdio for local clients such as MCP Inspector, Claude Desktop, or editor agents;
  • streamable-http to expose a POST /mcp endpoint. It listens on PORT, defaulting to 3000.

The CLI supports one mcp configuration object. Use the HTTP module when you need multiple MCP server endpoints.

Import CLI helpers from the CLI entry point and configure the same mcp provider arrays used by the HTTP module:

typescript
import {Configuration} from "@tsed/di";
import {defineTool, mcpServerConnect} from "@tsed/platform-mcp/cli";

const helloTool = defineTool({
  name: "hello",
  description: "Greets the MCP client",
  handler({name}: {name: string}) {
    return {content: [{type: "text", text: `Hello, ${name}!`}]};
  }
});

@Configuration({
  mcp: {
    name: "my-cli-mcp",
    version: "1.0.0",
    tools: [helloTool]
  }
})
export class McpConfiguration {}

// Call this after the CLI bootstrap has initialized the Ts.ED injector.
await mcpServerConnect("stdio");
// Or expose POST /mcp on process.env.PORT (default: 3000):
// await mcpServerConnect("streamable-http");

When using stdio, reserve standard output for the MCP protocol: do not write application logs or diagnostic output to stdout. The transport helper stops the Ts.ED logger before connecting, but child processes and custom logging must also write to stderr. For Streamable HTTP, protect the endpoint with authentication and run it behind the same network controls as other privileged CLI services.

Register tools ​

Tools expose executable actions to MCP clients. Use them for operations that take structured input and return content or structured results, such as querying a service, triggering a workflow, or composing data from your application. With decorators, the preferred approach is to declare Ts.ED models and let the framework derive both the input and the output schemas from the method signature and response metadata.

typescript
import {Injectable} from "@tsed/di";
import {Tool} from "@tsed/platform-mcp";
import {Description, Property, Returns} from "@tsed/schema";

class HelloInput {
  @Property()
  name: string;
}

class HelloOutput {
  @Property()
  message: string;
}

@Injectable()
export class HelloTool {
  @Tool("hello")
  @Description("Greets callers from any MCP client")
  @Returns(200, HelloOutput)
  async handle(input: HelloInput) {
    return {
      content: [
        {
          type: "text",
          text: `Hello, ${input.name}!`
        }
      ],
      structuredContent: {
        message: `Hello, ${input.name}!`
      }
    };
  }
}
typescript
import {defineTool} from "@tsed/platform-mcp";
import {s} from "@tsed/schema";

export const helloTool = defineTool({
  name: "hello",
  title: "Hello",
  description: "Greets callers from any MCP client",
  inputSchema: s
    .object({
      name: s.string().required()
    })
    .required(),
  outputSchema: s
    .object({
      message: s.string().required()
    })
    .required(),
  async handler({name}) {
    return {
      content: [
        {
          type: "text",
          text: `Hello, ${name}!`
        }
      ],
      structuredContent: {
        message: `Hello, ${name}!`
      }
    };
  }
});

Structured Response serialization v8.36.0+ ​

When a defineTool handler returns a plain object, Ts.ED serializes it with the tool name and the tools group, then creates both the JSON text content and structuredContent required by MCP. This lets handlers return their domain result directly instead of manually duplicating it in an MCP response.

The generated input and output schemas use the same groups and preserve property aliases. Return a complete MCP result only when you need custom content, such as multiple messages or a non-JSON content type; it is passed through unchanged.

typescript
import {Injectable} from "@tsed/di";
import {Tool} from "@tsed/platform-mcp";
import {Description, Property, Returns} from "@tsed/schema";

class HelloInput {
  @Property()
  name: string;
}

class HelloOutput {
  @Property()
  message: string;
}

@Injectable()
export class HelloTool {
  @Tool("hello")
  @Description("Greets callers from any MCP client")
  @Returns(HelloOutput)
  async handle(input: HelloInput) {
    return new HelloOutput({
      message: `Hello, ${input.name}!`
    });
  }
}
typescript
import {defineTool} from "@tsed/platform-mcp";
import {s} from "@tsed/schema";

export const helloTool = defineTool({
  name: "hello",
  title: "Hello",
  description: "Greets callers from any MCP client",
  inputSchema: s
    .object({
      name: s.string().required()
    })
    .required(),
  outputSchema: s
    .object({
      message: s.string().required()
    })
    .required(),
  async handler({name}) {
    return {
      message: `Hello, ${name}!`
    };
  }
});

Register resources ​

Resources expose addressable content that clients can discover and read later by URI. Use them for static or dynamic documents, generated files, configuration snapshots, or any other content that should be fetched as a resource.

typescript
import {Injectable} from "@tsed/di";
import {Resource} from "@tsed/platform-mcp";

@Injectable()
export class McpResources {
  @Resource("tsed://docs/index", {
    name: "docs",
    title: "Internal documentation",
    description: "Returns the internal MCP documentation"
  })
  readDocs() {
    return {
      contents: [
        {
          uri: "tsed://docs/index",
          mimeType: "text/markdown",
          text: "Internal doc content"
        }
      ]
    };
  }
}
typescript
import {defineResource} from "@tsed/platform-mcp";

export const docsResource = defineResource({
  name: "docs",
  title: "Internal documentation",
  description: "Returns the internal MCP documentation",
  uri: "tsed://docs/index",
  handler() {
    return {
      contents: [
        {
          uri: "tsed://docs/index",
          mimeType: "text/markdown",
          text: "Internal doc content"
        }
      ]
    };
  }
});

Register prompts ​

Prompts expose reusable prompt templates that MCP clients can request on demand. Use them to generate consistent conversation starters, assistant instructions, or parameterized user messages from your Ts.ED application.

typescript
import {Injectable} from "@tsed/di";
import {Prompt} from "@tsed/platform-mcp";

@Injectable()
export class McpPrompts {
  @Prompt({
    name: "ask-tsed",
    title: "Ask Ts.ED",
    description: "Creates a prompt message for Ts.ED questions"
  })
  askTsed({question}: {question: string}) {
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: question
          }
        }
      ]
    };
  }
}
typescript
import {definePrompt} from "@tsed/platform-mcp";
import {s} from "@tsed/schema";

export const askTsedPrompt = definePrompt({
  name: "ask-tsed",
  title: "Ask Ts.ED",
  description: "Creates a prompt message for Ts.ED questions",
  argsSchema: s
    .object({
      question: s.string().required()
    })
    .required(),
  handler({question}) {
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: question
          }
        }
      ]
    };
  }
});

Error handling ​

defineTool, defineResource, and definePrompt wrap handler execution in try/catch. When a handler throws, Ts.ED logs a structured event and returns a fallback MCP payload instead of re-throwing.

Error code resolution follows this rule:

  • if error.name and error.status are present: E_MCP_<KIND>_<CONSTANT_CASE(error.name)> (via change-case)
  • otherwise: E_MCP_<KIND>_ERROR

Tool errors ​

  • log event: MCP_TOOL_ERROR
  • fallback response:
json
{
  "content": [],
  "structuredContent": {
    "status_code": 500,
    "code": "E_MCP_TOOL_INTERNAL_SERVER_ERROR",
    "message": "Something went wrong",
    "request_id": "<tsed-di-context-id>",
    "tool": "my-tool"
  }
}

Resource errors ​

  • log event: MCP_RESOURCE_ERROR
  • fallback response:
json
{
  "contents": [],
  "_meta": {
    "status_code": 404,
    "code": "E_MCP_RESOURCE_NOT_FOUND",
    "message": "Resource not found",
    "request_id": "<tsed-di-context-id>",
    "resource": "docs"
  }
}

Prompt errors ​

  • log event: MCP_PROMPT_ERROR
  • fallback response:
json
{
  "description": "Prompt execution failed",
  "messages": [],
  "_meta": {
    "status_code": 400,
    "code": "E_MCP_PROMPT_BAD_REQUEST",
    "message": "Prompt execution failed",
    "request_id": "<tsed-di-context-id>",
    "prompt": "ask-tsed"
  }
}

Customising the endpoint ​

Set mcp.path or mcp.enabled to control how the transport is exposed:

typescript
@Configuration({
  mcp: {
    path: "/ai/mcp",
    enabled: process.env.MCP_DISABLED !== "true"
  }
})

Expose multiple MCP servers ​

mcp also accepts an array. Each entry creates an independent MCP server and mounts its own POST endpoint. This is useful when clients or domains need distinct endpoint paths, server metadata, or transport options.

typescript
import {Configuration} from "@tsed/di";
import "@tsed/platform-express";
import "@tsed/platform-mcp";

@Configuration({
  mcp: [
    {
      name: "catalog",
      path: "/mcp/catalog"
    },
    {
      name: "administration",
      path: "/mcp/admin",
      transportOptions: {enableJsonResponse: true}
    }
  ]
})
export class Server {}

Each configuration is resolved once while routes are initialized. A new McpServer and Streamable HTTP transport are created for every request, so concurrent requests and server shutdowns remain isolated. Set enabled: false on one entry to leave that endpoint unmounted without affecting the others.

Provider registration is declarative: a server only registers the tokens and decorated classes listed in its tools, resources, and prompts arrays. A decorated class contributes all of its handlers to that MCP server; it is not automatically exposed by other MCP configurations.

The CLI exposes a single /mcp endpoint; use one MCP configuration when starting it with mcpServerConnect.

All Ts.ED adapters (Express, Fastify, Koa) forward POST <path> requests to @modelcontextprotocol/sdk's StreamableHTTPServerTransport, so any MCP-capable client (Claude Desktop, etc.) can talk with your server regardless of the underlying framework.

Testing and inspector ​

With @tsed/platform-mcp, your MCP server is exposed through your Ts.ED HTTP application. The usual integration test strategy is to bootstrap the server with PlatformTest, then exercise POST /mcp with supertest to verify that tools, resources, and prompts are correctly registered and reachable through the transport.

typescript
const response = await request.post("/mcp").set({
  Accept: "application/json,text/event-stream",
  "Content-Type": "application/json"
});

If you want to inspect the server manually, start your Ts.ED application and point the MCP Inspector to the HTTP endpoint exposed by @tsed/platform-mcp:

bash
npx @modelcontextprotocol/inspector

Then configure the inspector to use the Streamable HTTP transport with your server URL, for example:

text
http://localhost:8083/mcp

TIP

If you also need a standalone CLI distribution that speaks MCP over stdio or standalone HTTP, use @tsed/cli-mcp. The decorators and function helpers are designed to stay close across both packages, so moving handlers between CLI and platform integrations does not require rewriting the MCP logic.

Released under the MIT License.