alepha@docs:~/docs/.content/release/framework/docs/guides/ai$
cat 2-mcp.md | pretty
16 min read
Last commit:

#MCP Server

The Model Context Protocol (MCP) lets AI assistants call your application's tools, read its resources, and use its prompt templates over a standard JSON-RPC protocol.

Alepha ships with first-class MCP support. You define tools, resources, and prompts with the same primitive pattern you already use for routes and actions. The framework handles protocol negotiation, schema validation, and transport. To call a model from your own app instead, see AI assistant.

#Quick Start

typescript
 1import { Alepha, z, run } from "alepha"; 2import { 3  AlephaMcp, 4  StreamableHttpMcpTransport, 5  $tool, 6  $resource, 7} from "alepha/mcp"; 8import { AlephaServer } from "alepha/server"; 9 10class MyMcp {11  add = $tool({12    description: "Add two numbers",13    schema: {14      params: z.object({15        a: z.number(),16        b: z.number(),17      }),18      result: z.number(),19    },20    handler: async ({ params }) => params.a + params.b,21  });22 23  readme = $resource({24    uri: "docs://readme",25    description: "Project README",26    mimeType: "text/markdown",27    handler: async () => ({28      text: "# My App\nWelcome to my application.",29    }),30  });31}32 33run(34  Alepha.create()35    .with(AlephaServer)36    .with(AlephaMcp)37    .with(StreamableHttpMcpTransport)38    .with(MyMcp),39);

Your MCP server is now available at POST /mcp (Streamable HTTP, JSON-RPC). Transports are opt-in - AlephaMcp provides the server; wiring StreamableHttpMcpTransport exposes it over HTTP.

#Three Primitives

MCP defines three types of capabilities. Each maps to an Alepha primitive.

#$tool: Callable Functions

Tools let an AI assistant perform actions: query a database, create records, call external APIs.

typescript
 1import { $tool } from "alepha/mcp"; 2  3class TaskTools { 4  // a plain service with its own list() contract - to expose an existing 5  // $action instead, call it with { query: { ... } } and map its page shape 6  protected readonly tasks = $inject(TaskService); 7  8  task_list = $tool({ 9    description: "List tasks. Filter by status or search by title.",10    schema: {11      params: z.object({12        status: z.enum(["new", "accepted", "completed"]).optional(),13        search: z.text({ description: "Search by title" }).optional(),14        limit: z.integer().min(1).max(100).optional(),15      }),16      result: z.object({17        tasks: z.array(18          z.object({19            id: z.integer(),20            title: z.text(),21            status: z.text(),22          }),23        ),24        total: z.integer(),25      }),26    },27    handler: async ({ params }) => {28      const result = await this.tasks.list({29        status: params.status,30        search: params.search,31        limit: params.limit ?? 20,32      });33      return { tasks: result.items, total: result.total };34    },35  });36 37  task_create = $tool({38    description: "Create a new task.",39    schema: {40      params: z.object({41        title: z.text(),42        description: z.text().optional(),43        priority: z.enum(["low", "medium", "high"]).optional(),44      }),45      result: z.object({46        id: z.integer(),47        title: z.text(),48      }),49    },50    handler: async ({ params }) => {51      return await this.tasks.create(params);52    },53  });54}

Options:

Option Type Description
description string Required. Tells the AI what the tool does.
schema.params ZObject Zod schema for input parameters.
schema.result ZType Zod schema for the return value.
handler function Receives { params, context, user }. Returns the result.
name string Override the tool name. Defaults to the property key.
title string Human-friendly display name for client tool pickers.
annotations object Behavior hints, see below.
icons array Icons shown in client tool palettes.
_meta object Arbitrary metadata passed through on the descriptor.
use Middleware[] Middlewares run around the handler, e.g. $secure(). See Authentication.
timeout DurationLike | false Abort the call after this long. See Timeouts.

annotations are hints for the client, not guarantees: readOnlyHint, destructiveHint, idempotentHint, openWorldHint (and a title). A client may ask for confirmation before running a tool marked destructiveHint: true.

Parameters and results are validated automatically. Invalid parameters come back as a tool execution error the model can read and retry; a result that breaks schema.result is a server bug and comes back as a JSON-RPC error. See Error Handling.

Non-object results travel under result. MCP requires the structuredContent of a response to be an object, so a tool declaring a scalar or a union - result: z.number(), as in the quick start above - advertises { result: <schema> } and answers structuredContent: { result: 42 }. Declare schema.result as an object when you want the fields at the top level. Either way the text content block carries the value as it always did, so a client reading content[0].text sees no difference.

Returning images or binary content: when a tool needs to hand the client a screenshot, a chart, or any non-JSON payload, omit schema.result and return raw MCP content blocks instead - { content: [...] }, where each block is { type: "text", text }, { type: "image", data, mimeType } (base64), { type: "audio", data, mimeType }, or a resource link. The blocks are passed through to the client verbatim, so an image block renders inline in clients that support it.

typescript
 1screenshot = $tool({ 2  description: "Capture the current page as a PNG.", 3  // No `schema.result` - the handler returns content blocks directly. 4  handler: async ({ params }) => { 5    const png = await this.capture(params.url); // Buffer 6    return { 7      content: [ 8        { type: "image", data: png.toString("base64"), mimeType: "image/png" }, 9      ],10    };11  },12});

A tool that declares schema.result always goes through the structured/JSON path, so a JSON result that happens to contain a content array is never mistaken for raw content.

#$resource: Read-Only Data

Resources expose data that an AI can read but not modify: configuration, documentation, database snapshots.

typescript
 1import { $resource } from "alepha/mcp"; 2  3class Resources { 4  projectList = $resource({ 5    uri: "app://projects", 6    description: "All projects the user has access to.", 7    mimeType: "application/json", 8    handler: async () => { 9      const projects = await this.projectController.list();10      return {11        text: JSON.stringify(projects, null, 2),12      };13    },14  });15 16  logo = $resource({17    uri: "app://logo",18    mimeType: "image/png",19    handler: async () => ({20      blob: await fs.readFile("logo.png"),21    }),22  });23}

Options:

Option Type Description
uri string Required. Unique identifier (e.g. app://projects, file:///readme).
description string What this resource contains.
mimeType string Content type. Defaults to text/plain.
handler function Receives { context }. Returns { text } for text content or { blob } (a Uint8Array) for binary.
name string Display name. Defaults to the property key.
title string Human-friendly display name for client pickers.
icons array Icons shown in client resource lists.
annotations object audience, priority and lastModified hints. lastModified lets a client skip re-reading a copy still current.
_meta object Arbitrary metadata passed through on the descriptor.
cache object { ttlMs, cacheScope } hints on resources/read, modern clients only. See Caching hints.

#$resourceTemplate: Parameterized Resources

$resource addresses one thing at a fixed URI. $resourceTemplate addresses a family of them, so an AI can read folio://1/86 without you registering every folio up front:

typescript
 1import { $resourceTemplate } from "alepha/mcp"; 2  3class FolioResources { 4  folio = $resourceTemplate({ 5    uriTemplate: "folio://{projectId}/{shortId}", 6    description: "A folio, by project and short id.", 7    mimeType: "text/markdown", 8    variables: z.object({ 9      projectId: z.text(),10      shortId: z.text(),11    }),12    handler: async ({ variables }) => {13      const folio = await this.folios.find(14        variables.projectId,15        variables.shortId,16      );17      // `undefined` means "well-formed URI, nothing there" -> not found.18      return folio ? { text: folio.content } : undefined;19    },20  });21}

Templates are advertised on resources/templates/list, and resources/read falls through to them when no fixed resource matches the URI exactly. A concrete $resource always wins over a template that also matches - registering db://users/me alongside db://users/{id} does what you would expect.

URI templates. Two RFC 6570 forms are supported:

Form Matches Use for
{var} one segment, never spanning /; percent-decoded ids, slugs
{+var} greedy, / included; not decoded trailing paths (file:///{+path})

Any other operator ({?query}, {#frag}, {/path*}) throws when the container wires the primitive up, rather than compiling into a pattern that silently never matches.

Options:

Option Type Description
uriTemplate string Required. The RFC 6570 pattern.
variables ZObject Validates the extracted values. A failure is -32602, so a malformed URI never reaches the handler.
handler function Receives { variables, uri, context }. Returns { text }, { blob }, or undefined for not found.
description string What this family of resources contains.
mimeType string Content type. Defaults to text/plain.
name string Display name. Defaults to the property key.
complete function Suggests values for a variable, see argument completion below.
title, icons, annotations, _meta, cache Same as on $resource.

A template must contain at least one variable (a fixed URI is a $resource), and a variable may appear only once. Both mistakes throw when the primitive is wired up, like an unsupported operator.

#$prompt: Message Templates

Prompts define reusable conversation templates with typed arguments.

typescript
 1import { $prompt } from "alepha/mcp"; 2  3class Prompts { 4  codeReview = $prompt({ 5    description: "Request a code review", 6    args: z.object({ 7      code: z.text({ description: "The code to review" }), 8      language: z.text({ description: "Programming language" }), 9    }),10    handler: async ({ args }) => [11      {12        role: "user",13        content: `Review this ${args.language} code:\n\n\`\`\`${args.language}\n${args.code}\n\`\`\``,14      },15    ],16  });17}

Options:

Option Type Description
description string What this prompt does.
args ZObject Zod schema for template arguments.
handler function Returns an array of { role, content } messages.
name string Override the prompt name. Defaults to the property key.
complete function Suggests values for an argument, see below.
title string Human-friendly display name for client prompt pickers.
icons array Icons shown in client prompt lists.
_meta object Arbitrary metadata passed through on the descriptor.

A message's content is a string (sent as a text block), one MCP content block, or an array of them.

Argument completion. A complete handler on a $prompt (or a $resourceTemplate, for its variables) answers completion/complete, so a client can offer values as the user types. It receives { argument: { name, value }, arguments?, context? } and returns candidate strings. Declaring one anywhere is what makes the server advertise the completions capability.

typescript
 1codeReview = $prompt({ 2  args: z.object({ code: z.text(), language: z.text() }), 3  complete: async ({ argument }) => 4    argument.name === "language" 5      ? ["typescript", "python", "go"].filter((l) => 6          l.startsWith(argument.value), 7        ) 8      : [], 9  handler: async ({ args }) => [{ role: "user", content: args.code }],10});

#Wiring It Up

Register the AlephaMcp module and your tool/resource/prompt classes:

typescript
 1import { Alepha, run } from "alepha"; 2import { AlephaMcp, StreamableHttpMcpTransport } from "alepha/mcp"; 3import { AlephaServer } from "alepha/server"; 4  5run( 6  Alepha.create() 7    .with(AlephaServer) 8    .with(AlephaMcp) 9    .with(StreamableHttpMcpTransport)10    .with(TaskTools)11    .with(Resources)12    .with(Prompts),13);

Primitives auto-register with the MCP server when instantiated - only the transport needs explicit wiring.

For larger apps, group MCP classes into a module:

typescript
1import { $module } from "alepha";2import { StreamableHttpMcpTransport } from "alepha/mcp";3 4export const MyAppMcp = $module({5  name: "myapp.mcp",6  services: [StreamableHttpMcpTransport, TaskTools, ProjectTools, Resources],7});

Then register the module alongside your other modules:

typescript
1run(Alepha.create().with(AlephaServer).with(MyAppApi).with(MyAppMcp));

#Using DI in Tools

Tools, resources, and prompts are regular Alepha classes. Use $inject() to access any service:

typescript
 1class PostTools { 2  protected posts = $repository(postEntity); 3  protected markdown = $inject(MarkdownProvider); 4  5  post_create = $tool({ 6    description: "Create a new blog post.", 7    schema: { 8      params: z.object({ 9        title: z.text(),10        content: z.text({ description: "Markdown content" }),11        tags: z.array(z.text()).optional(),12      }),13    },14    handler: async ({ params }) => {15      const html = this.markdown.render(params.content);16      return await this.posts.create({17        title: params.title,18        content: params.content,19        contentHtml: html,20        tags: params.tags ?? [],21      });22    },23  });24}

#Schemas

Zod schemas on tools serve double duty:

  1. Runtime validation: params are validated before your handler runs, results are validated before being sent back
  2. JSON Schema generation: the MCP protocol advertises your tool's input schema so AI clients know what to send

Add description to individual fields to help the AI understand what each parameter does:

typescript
 1schema: { 2  params: z.object({ 3    project: z.integer().describe("Project ID").optional(), 4    project_name: z.text({ description: "Case-insensitive project name" }).optional(), 5    limit: z.integer() 6      .min(1) 7      .max(100) 8      .describe("Max results to return (default: 20)") 9      .optional(),10  }),11}

Extract shared schemas to keep tool definitions clean:

typescript
 1// schemas/common.ts 2export const projectParamsSchema = z.object({ 3  project: z.integer().describe("Project ID").optional(), 4  project_name: z 5    .text({ description: "Project name (case-insensitive)" }) 6    .optional(), 7}); 8  9// tools/TaskTools.ts10import { projectParamsSchema } from "../schemas/common.ts";11 12task_list = $tool({13  description: "List tasks for a project.",14  schema: {15    params: projectParamsSchema.extend({16      status: z.enum(["new", "accepted", "completed"]).optional(),17    }),18  },19  handler: async ({ params }) => {20    /* ... */21  },22});

#Authentication

A tool requires a user, a role or a permission the way an action does, with use: [$secure(...)]. Its handler then receives a non-optional user:

typescript
1import { $secure } from "alepha/security";2 3task_list = $tool({4  description: "List the caller's tasks.",5  use: [$secure({ permissions: ["tasks:read"] })],6  handler: async ({ user }) => {7    return this.tasks.findMany({ where: { ownerId: user.id } });8  },9});

An anonymous call fails with a 401, a caller without the role or permission with a 403, and the AI client reads either as a tool error.

The transport's requireAuth (see Streamable HTTP) is a different thing: the gate on the whole endpoint, in front of every tool. It answers 401 with the WWW-Authenticate challenge an MCP client's OAuth discovery needs, before any tool is resolved. Turn it on for an endpoint that serves only signed-in callers, and put the per-tool rules in $secure.

#Context

Every handler also receives an optional context with the HTTP headers and custom data. context.data carries whatever the transport put there; by default that is the authenticated user (request.user).

To carry anything else, such as a project scope or request id, override buildContext on the transport and register the subclass:

typescript
 1import { StreamableHttpMcpTransport } from "alepha/mcp"; 2  3class MyMcpTransport extends StreamableHttpMcpTransport { 4  protected buildContext(request: any) { 5    return { 6      ...super.buildContext(request), 7      data: { user: request.user, projectId: request.headers["x-project-id"] }, 8    }; 9  }10}11 12alepha.with({ provide: StreamableHttpMcpTransport, use: MyMcpTransport });

A request in the modern protocol (2026-07-28, see Protocol revisions) also says who is calling on every request, and the context carries it: context.protocolVersion, context.clientInfo (self-reported, fine for logs, never for a decision) and context.clientCapabilities ({} when the client declares none). All three are undefined on a legacy request, which states them once in initialize and is not remembered by this stateless server.

#Error Handling

Throw errors in handlers and they are returned as tool results the AI can read:

typescript
1import { NotFoundError } from "alepha/server";2 3handler: async ({ params }) => {4  const project = await this.projects.findById(params.id);5  if (!project) {6    throw new NotFoundError(`Project ${params.id} not found`);7  }8  return project;9};

An ordinary Error becomes a tool execution error (isError: true with the message as text) so the model can read it and self-correct. An McpError subclass is a JSON-RPC protocol error instead, carrying its code - use one when the caller cannot fix the problem by changing its arguments.

The server logs every tool execution error, at a level set by the error's status. A refusal, anything with a status below 500 such as the NotFoundError above, is logged at warn as MCP tool "<name>" refused the call, with the status, the class name and the message. Anything else, a 5xx or an error with no status at all, is logged at error with its full chain. So an agent correcting itself does not bury the tool that actually broke.

Available error classes:

Error Code When to use
McpUnauthorizedError -31001 Missing or invalid credentials
McpForbiddenError -31003 Authenticated but not allowed
McpToolNotFoundError -32602 Unknown tool name
McpResourceNotFoundError -32602 Unknown resource URI
McpPromptNotFoundError -32602 Unknown prompt name
McpInvalidParamsError -32602 Bad parameters
McpToolOutputError -32603 A tool returned a value violating its own schema.result (server raised, not thrown by you)

The two permission codes sit outside the JSON-RPC reserved range (-32768..-32000) on purpose: MCP 2026-07-28 reserves that range for JSON-RPC and for codes the MCP specification itself defines. They were -32001 and -32003 before.

Unknown names are -32602 Invalid params, not -32601 Method not found: -32601 says the method tools/call does not exist, which a client can read as "this server has no tools at all".

Input validation stays a tool execution error - the model sent bad arguments and can retry. Output validation does not: a handler that breaks its own schema.result is a server bug, so it is logged and returned as -32603, never as a validation error pointing at an input path the caller never sent.

#Timeouts

A tool has no time limit by default. Give one with timeout, which bounds the middlewares and the handler together:

typescript
1search = $tool({2  description: "Full-text search across every project.",3  schema: { params: z.object({ q: z.text() }) },4  timeout: [30, "seconds"],5  handler: async ({ params }) =>6    this.index.search(params.q, this.alepha.context.signal),7});

A call that runs out of time returns a tool execution error (isError: true, "Timed out after 30 s"), not a JSON-RPC error, so the model can retry or narrow its request. The handler's context.signal, the same object as alepha.context.signal, aborts with a TimeoutError, so outbound calls and queries that honour it stop too.

To set a default for every tool, write the mcpToolOptions atom (milliseconds; unset or 0 means none). A tool's own timeout wins, and timeout: false opts one tool out:

typescript
1import { mcpToolOptions } from "alepha/mcp";2 3alepha.store.mut(mcpToolOptions, (o) => ({ ...o, timeout: 30_000 }));

#Transport

Transports are opt-in: wire the one you need.

#Streamable HTTP

Streamable HTTP (MCP spec 2025-03-26+), a single endpoint:

  • POST /mcp: JSON-RPC endpoint; single responses return application/json
  • GET /mcp and DELETE /mcp: return 405 Method Not Allowed (no standalone SSE stream, no session to end)

The mcpStreamableHttpOptions atom configures it:

Option Default Description
path "/mcp" Endpoint path. Keep it outside /api, which belongs to the $action dispatcher.
allowedOrigins [] (any) Origin values a browser request may carry; any other is a 403 (DNS-rebinding guard). Requests with no Origin always pass.
requireAuth false Refuse an unauthenticated POST with 401 and an RFC 9728 WWW-Authenticate challenge, which MCP clients use to find the OAuth server. $realm({ features: { oauth: true } }) turns it on.
resourceMetadataPath "/.well-known/oauth-protected-resource" Protected-resource metadata document advertised in that challenge.
typescript
1import { mcpStreamableHttpOptions } from "alepha/mcp";2 3alepha.store.mut(mcpStreamableHttpOptions, (o) => ({ ...o, path: "/my-mcp" }));

⚠️ Set path before wiring the transport. The transport declares its routes when it is constructed, and .with(StreamableHttpMcpTransport) constructs it at once, so a path written afterwards is never read. allowedOrigins, requireAuth and resourceMetadataPath are read on every request and can change at any time.

#stdio: local servers

Claude Desktop, Claude Code and every other local client launch the server as a subprocess and speak newline-delimited JSON-RPC over its pipes:

typescript
1import { Alepha, run } from "alepha";2import { AlephaMcp, StdioMcpTransport } from "alepha/mcp";3 4run(Alepha.create().with(AlephaMcp).with(StdioMcpTransport).with(MyTools));

Then point the client at the built binary:

json
1{2  "mcpServers": {3    "my-app": {4      "command": "node",5      "args": ["/path/to/my-app/dist/index.node.js"],6      "env": { "DATABASE_URL": "..." }7    }8  }9}

stdout belongs to the protocol. A single stray console.log - yours, Alepha's, or a dependency's - lands inside a JSON-RPC message and corrupts the stream permanently. While this transport runs it redirects process.stdout to stderr and keeps the real stdout for protocol messages only, so your logs still appear (on stderr, where the spec wants them) and cannot break the stream.

A stdio server takes credentials from its environment rather than the HTTP authorization framework, so requireAuth has no meaning there - whoever launched the process is the caller.

#Protocol revisions

One endpoint serves two eras of MCP at once, on HTTP and on stdio alike:

  • Modern, 2026-07-28 (MCP_PROTOCOL_VERSION). No handshake: every request carries its protocol version, client identity and capabilities in params._meta, and a client may call server/discover first to learn what the server supports.
  • Legacy, 2025-11-25 back to 2024-11-05 (LEGACY_PROTOCOL_VERSIONS). The client opens with initialize and is served exactly as before. Claude Code still connects this way.

The server keeps no session, so it cannot remember how a client opened. The era is decided per request: a request is modern when its _meta["io.modelcontextprotocol/protocolVersion"] or its MCP-Protocol-Version header names a version outside the legacy list. Anything else, and initialize always, is legacy. initialize only ever negotiates a legacy version.

Flowchart diagramyesnoyesyesyesnononono, or no headeryesIncoming requestinitialize?Served as legacyprotocolVersions holdsa modern version?Non-legacy version in _metaor MCP-Protocol-Version?Supported?Served as modernJSON-RPC -32022MCP-Protocol-Version headeroutside protocolVersions?Plain 400client falls back to initialize

Over HTTP a -32022 comes with a 400. Stdio has no headers: _meta alone names the version, and neither 400 exists there.

What a modern request gets that a legacy one never does:

  • server/discover: the supported versions (modern first) and the capabilities. A legacy request calling it gets -32601.
  • A result envelope: resultType: "complete" on every result and the server's identity in _meta["io.modelcontextprotocol/serverInfo"].
  • Caching hints (ttlMs, cacheScope) on server/discover, the four lists and resources/read, described below.
  • Strict header checks on HTTP: MCP-Protocol-Version, Mcp-Method, and Mcp-Name on tools/call, resources/read and prompts/get, must be present and must match the body (Mcp-Name may be sent as =?base64?...?=). A missing or disagreeing header is 400 with -32020.
  • HTTP statuses for protocol failures: an unsupported version is 400 with -32022 listing what is supported, an unknown method (ping included, which 2026-07-28 removed) is 404 with -32601. Every other JSON-RPC error, and every legacy error, stays 200.

The switch is McpServerProvider.protocolVersions, seeded from SUPPORTED_PROTOCOL_VERSIONS. The modern protocol is on exactly when that list holds a modern version. To serve legacy clients only:

typescript
1import { LEGACY_PROTOCOL_VERSIONS, McpServerProvider } from "alepha/mcp";2 3alepha.inject(McpServerProvider).protocolVersions = [4  ...LEGACY_PROTOCOL_VERSIONS,5];

With no modern version listed, a request naming one gets a plain 400 whose body is not a JSON-RPC error: that is what makes a dual-era client such as claude.ai fall back to initialize.

#Caching hints

A modern client may cache a result for ttlMs milliseconds; cacheScope says whether a shared cache may hand it to other callers ("public") or only to the same authorization context ("private"). The defaults:

Result ttlMs cacheScope
server/discover, tools/list, prompts/list, resources/list, resources/templates/list 300000 "public"
resources/read 0 "private"

Lists are public because every caller gets the same registry, and five minutes bounds how long a client keeps a list from before a deploy. Reads are private and immediately stale because resource content usually depends on the caller. Override them where you know better:

typescript
1const mcp = alepha.inject(McpServerProvider);2mcp.listCache = { ttlMs: 60_000, cacheScope: "private" }; // lists filtered per user3mcp.discoverCache = { ttlMs: 3_600_000, cacheScope: "public" };4 5changelog = $resource({6  uri: "docs://changelog",7  cache: { ttlMs: 600_000, cacheScope: "public" }, // same for everyone8  handler: async () => ({ text: await this.changelog.render() }),9});

$resourceTemplate takes the same cache option. Set "public" only on content that is identical for every caller.

#Progress on long calls

When a client attaches a _meta.progressToken to a request, the HTTP response upgrades to text/event-stream: progress notifications as they happen, then the final response. Without a token, nothing changes - the response is plain JSON.

typescript
 1index_repo = $tool({ 2  description: "Index every file in the repository.", 3  handler: async ({ context }) => { 4    const files = await this.files.list(); 5    for (const [i, file] of files.entries()) { 6      await this.index(file); 7      context?.reportProgress?.(i + 1, files.length, `Indexed ${file.name}`); 8    } 9    return { indexed: files.length };10  },11});

reportProgress is absent when the client did not ask for progress, so call it through ?.. The same context carries a signal that aborts when the client cancels - pass it to fetch, DB queries and anything else that accepts one, or a tool nobody is waiting for keeps running to completion.

#Paginated lists

tools/list, resources/list, resources/templates/list and prompts/list page through an opaque cursor. The default page size is 100; lower it when the descriptor blob is what you are trying to keep out of the model's context:

typescript
1alepha.inject(McpServerProvider).pageSize = 25;

#Observing tool calls

Every tools/call emits mcp:tool:call:end once, with { name, outcome, context }. outcome is "ok", "refused" (bad arguments, a missing row, a caller who may not: usually a misleading description or schema) or "error" (a fault). name is what the client asked for, registered or not. It is awaited inside the call, so a subscriber must be fast and must not throw.

typescript
1toolUsage = $hook({2  on: "mcp:tool:call:end",3  handler: ({ name, outcome }) => this.metrics.count(name, outcome),4});

#Naming Convention

Use entity_action for tool names (snake_case with underscore separator):

txt
project_list, project_info
task_create, task_update, task_complete
chapter_start, chapter_close, chapter_changelog

This groups related tools together and reads naturally in AI conversations.

#Project Structure

For apps with multiple MCP tools, organize by domain:

txt
src/
  mcp/
    index.ts              # $module definition
    schemas/
      common.ts           # Shared schemas (pagination, project params)
      taskSchemas.ts
      projectSchemas.ts
    tools/
      TaskTools.ts
      ProjectTools.ts
    resources/
      ProjectResources.ts