Guide: Build an MCP server

Expose tools, resources, and prompts that an MCP client — Claude Desktop, the Vercel AI SDK, your own agent — can discover and call. It's the same decorator-on-a-class, schema-on-the-decorator shape as REST, so if you've read the REST guide this will feel familiar.

Package: @agentback/mcp. Built on the official @modelcontextprotocol/sdk. Working examples: examples/hello-mcp (stdio) and examples/hello-hybrid (REST + MCP).

What MCP exposes

Primitive Decorator An LLM uses it to…
Tool @tool(name, {input, output}) do something (call a function with typed args).
Resource @resource(uri, {...}) read context (a document/blob addressed by URI).
Prompt @prompt(name, {...}) fetch a reusable prompt template.

A class tagged @mcpServer() groups these; the MCPServer discovers them and registers them with the SDK.

1. A tool class

import {z} from 'zod';
import {mcpServer, tool} from '@agentback/mcp';

const EchoIn = z.object({text: z.string().min(1)});
const AddIn = z.object({a: z.number().int(), b: z.number().int()});
const AddOut = z.object({sum: z.number().int()});

@mcpServer()
export class MathTools {
  @tool('echo', {description: 'Echo back the text', input: EchoIn})
  echo(input: z.infer<typeof EchoIn>) {
    return {echoed: input.text};
  }

  @tool('add', {description: 'Add two integers', input: AddIn, output: AddOut})
  add(input: z.infer<typeof AddIn>): z.infer<typeof AddOut> {
    return {sum: input.a + input.b};
  }
}

input is validated and typed via z.infer. Declaring output constrains the return type at compile time, validates it at runtime, and hands the schema to the MCP SDK so structured-content clients consume it directly. Omit output to leave the return type free.

2. Resources and prompts

In this framework, resources and prompts are parameterless methods — the handler takes no input and returns a value the framework wraps into the MCP wire shape.

import {resource, prompt} from '@agentback/mcp';

@mcpServer()
export class Docs {
  @resource('docs://motd', {
    name: 'motd',
    description: 'Message of the day',
    mimeType: 'text/plain',
  })
  motd() {
    return 'Welcome to the service.'; // → {contents: [{uri, mimeType, text}]}
  }

  @prompt('triage', {description: 'Triage a bug report'})
  triage() {
    return 'You are a triage assistant. Classify the report by severity…';
    // → {messages: [{role: 'user', content: {type: 'text', text}}]}
  }
}

A string return is sent as text; a non-string is JSON-stringified.

3. Run the server

Add MCPComponent (it binds servers.MCPServer), register your tool classes with app.service(...), configure, start. The stdio transport is active by default — exactly what Claude Desktop and most local MCP clients speak.

A tool class is a DI service. @mcpServer() (built on @injectable) makes it an extension of the MCP_SERVERS extension point and defaults it to singleton scope. The server discovers it by that extension and resolves it through its binding, so constructor @inject is honored no matter how it was registered — see §4.

import {RestApplication} from '@agentback/rest';
import {MCPComponent} from '@agentback/mcp';
import {MathTools, Docs} from './tools.js';

const app = new RestApplication();
app.component(MCPComponent);
app.configure('servers.MCPServer').to({
  name: 'my-mcp',
  version: '1.0.0',
  // transports: {stdio: true},  // default; set false in tests/hybrid HTTP apps
});
app.service(MathTools);
app.service(Docs);
await app.start(); // MCP server up, tools/resources/prompts registered

You can use a plain Application instead of RestApplication if you only want MCP and no HTTP. RestApplication is convenient when you also want the inspector UI (which is served over HTTP).

Expose it over Streamable HTTP

stdio is great for a local child process, but to let remote MCP clients (Claude, Cursor, agents) reach the same tools, expose the in-process server over the MCP Streamable HTTP transport with @agentback/mcp-http:

import {installMcpHttp} from '@agentback/mcp-http';

await installMcpHttp(app); // before app.start() — mounts POST/GET/DELETE /mcp

Each client session gets its own isolated server instance (keyed by the Mcp-Session-Id header). A client connects with the SDK's StreamableHTTPClientTransport:

import {Client} from '@modelcontextprotocol/sdk/client/index.js';
import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const client = new Client({name: 'my-client', version: '1.0.0'});
await client.connect(
  new StreamableHTTPClientTransport(new URL('http://host:port/mcp')),
);
await client.callTool({name: 'add', arguments: {a: 2, b: 40}});

This works alongside REST in one process (examples/hello-hybrid mounts /mcp, /explorer, and /mcp-inspector together). To protect /mcp as an OAuth 2.1 resource server, pass installMcpHttp(app, {auth: {...}}) — see the @agentback/mcp-http README. Tag a tool with @tool('x', {scope: 'admin'}) to gate it by the caller's token scopes.

How discovery works

@mcpServer() is built on @injectable: it tags the class as an extension of the MCP_SERVERS extension point (extensionFor: MCP_SERVERS) and defaults it to singleton scope. When you call app.service(MathTools), the framework reads that metadata and tags the binding — you never call .tag(). At app.start(), MCPServer discovers the MCP_SERVERS extensions, reflects over their @tool/@resource/@prompt methods, and registers each with the SDK — resolving the instance through its own binding, so constructor @inject works.

Architecture diagram — text source below
diagram source (mermaid)
graph LR
  D["@mcpServer() class"] -->|"app.service()"| B["binding [extensionFor: MCP_SERVERS]"]
  B -->|"start(): extensionFilter"| M["MCPServer"]
  M -->|reflect @tool/@resource/@prompt| SDK["@modelcontextprotocol/sdk"]

Consequence: adding a tool is adding a method (or a class + app.service). Nothing central changes — the same composability as REST controllers.

4. Dependency injection in tools

Tools are DI-resolved classes, so inject services like anywhere else. With an input schema, injects go at slot 1+ (slot 0 is the validated input); with no input, slot 0 is free.

@mcpServer()
class WeatherTools {
  constructor(@inject('services.WeatherApi') private api: WeatherApi) {}

  @tool('forecast', {input: z.object({city: z.string()})})
  async forecast(input: {city: string}) {
    return this.api.lookup(input.city);
  }
}

Register it with app.service(WeatherTools) — a tool is a service. The dispatcher discovers it as an MCP_SERVERS extension and resolves the instance through that binding, so this.api is injected. This holds for any registration that carries the extension tag — app.service, app.controller, or a manual bind().apply(extensionFor(MCP_SERVERS)). (A dual REST + MCP class — @api and @mcpServer — still needs both restController for the routes and service for the MCP extension, since restController tags it for REST only.)

5. Inspect it: the MCP Inspector

A browser UI to list and exercise tools/resources/prompts without wiring up an MCP client:

import {installInspector} from '@agentback/mcp-inspector';

await installInspector(app); // before app.start(); needs servers.MCPServer bound
// -> UI at /mcp-inspector/, JSON API at /mcp-inspector/api/*

The inspector fills tool argument forms from each tool's Zod-derived JSON Schema, shows validation errors inline, renders results, and logs a call history. (It's itself a worked example of a dogfooded REST controller — see its README.)

Calling tools programmatically

MCPServer exposes the same dispatch path the SDK uses, handy for tests or in-process use:

const mcp = await app.get<MCPServer>('servers.MCPServer');
await mcp.callTool('add', {a: 2, b: 40}); // → {sum: 42}
await mcp.readResource('motd'); // → {contents: [...]}
await mcp.getPrompt('triage'); // → {messages: [...]}

Invalid input throws with the structured Zod issues attached.

Testing an MCP server

examples/hello-mcp drives the server with a real MCP client over stdio (transports: {stdio: true} and an in-memory client pair). For unit-style checks, prefer callTool/readResource/getPrompt against an app with transports: {stdio: false} so the server doesn't take over the process's stdin.

Next