# MCP Server

The [Model Context Protocol](https://modelcontextprotocol.io) (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](https://alepha.dev/docs/guides-ai-assistant.md).

## Quick Start

```typescript
import { Alepha, z, run } from "alepha";
import {
  AlephaMcp,
  StreamableHttpMcpTransport,
  $tool,
  $resource,
} from "alepha/mcp";
import { AlephaServer } from "alepha/server";

class MyMcp {
  add = $tool({
    description: "Add two numbers",
    schema: {
      params: z.object({
        a: z.number(),
        b: z.number(),
      }),
      result: z.number(),
    },
    handler: async ({ params }) => params.a + params.b,
  });

  readme = $resource({
    uri: "docs://readme",
    description: "Project README",
    mimeType: "text/markdown",
    handler: async () => ({
      text: "# My App\nWelcome to my application.",
    }),
  });
}

run(
  Alepha.create()
    .with(AlephaServer)
    .with(AlephaMcp)
    .with(StreamableHttpMcpTransport)
    .with(MyMcp),
);
```

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
import { $tool } from "alepha/mcp";

class TaskTools {
  // a plain service with its own list() contract - to expose an existing
  // $action instead, call it with { query: { ... } } and map its page shape
  protected readonly tasks = $inject(TaskService);

  task_list = $tool({
    description: "List tasks. Filter by status or search by title.",
    schema: {
      params: z.object({
        status: z.enum(["new", "accepted", "completed"]).optional(),
        search: z.text({ description: "Search by title" }).optional(),
        limit: z.integer().min(1).max(100).optional(),
      }),
      result: z.object({
        tasks: z.array(
          z.object({
            id: z.integer(),
            title: z.text(),
            status: z.text(),
          }),
        ),
        total: z.integer(),
      }),
    },
    handler: async ({ params }) => {
      const result = await this.tasks.list({
        status: params.status,
        search: params.search,
        limit: params.limit ?? 20,
      });
      return { tasks: result.items, total: result.total };
    },
  });

  task_create = $tool({
    description: "Create a new task.",
    schema: {
      params: z.object({
        title: z.text(),
        description: z.text().optional(),
        priority: z.enum(["low", "medium", "high"]).optional(),
      }),
      result: z.object({
        id: z.integer(),
        title: z.text(),
      }),
    },
    handler: async ({ params }) => {
      return await this.tasks.create(params);
    },
  });
}
```

**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](#authentication). |
| `timeout`       | `DurationLike \| false` | Abort the call after this long. See [Timeouts](#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](#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
screenshot = $tool({
  description: "Capture the current page as a PNG.",
  // No `schema.result` - the handler returns content blocks directly.
  handler: async ({ params }) => {
    const png = await this.capture(params.url); // Buffer
    return {
      content: [
        { type: "image", data: png.toString("base64"), mimeType: "image/png" },
      ],
    };
  },
});
```

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
import { $resource } from "alepha/mcp";

class Resources {
  projectList = $resource({
    uri: "app://projects",
    description: "All projects the user has access to.",
    mimeType: "application/json",
    handler: async () => {
      const projects = await this.projectController.list();
      return {
        text: JSON.stringify(projects, null, 2),
      };
    },
  });

  logo = $resource({
    uri: "app://logo",
    mimeType: "image/png",
    handler: async () => ({
      blob: await fs.readFile("logo.png"),
    }),
  });
}
```

**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](#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
import { $resourceTemplate } from "alepha/mcp";

class FolioResources {
  folio = $resourceTemplate({
    uriTemplate: "folio://{projectId}/{shortId}",
    description: "A folio, by project and short id.",
    mimeType: "text/markdown",
    variables: z.object({
      projectId: z.text(),
      shortId: z.text(),
    }),
    handler: async ({ variables }) => {
      const folio = await this.folios.find(
        variables.projectId,
        variables.shortId,
      );
      // `undefined` means "well-formed URI, nothing there" -> not found.
      return folio ? { text: folio.content } : undefined;
    },
  });
}
```

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
import { $prompt } from "alepha/mcp";

class Prompts {
  codeReview = $prompt({
    description: "Request a code review",
    args: z.object({
      code: z.text({ description: "The code to review" }),
      language: z.text({ description: "Programming language" }),
    }),
    handler: async ({ args }) => [
      {
        role: "user",
        content: `Review this ${args.language} code:\n\n\`\`\`${args.language}\n${args.code}\n\`\`\``,
      },
    ],
  });
}
```

**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
codeReview = $prompt({
  args: z.object({ code: z.text(), language: z.text() }),
  complete: async ({ argument }) =>
    argument.name === "language"
      ? ["typescript", "python", "go"].filter((l) =>
          l.startsWith(argument.value),
        )
      : [],
  handler: async ({ args }) => [{ role: "user", content: args.code }],
});
```

## Wiring It Up

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

```typescript
import { Alepha, run } from "alepha";
import { AlephaMcp, StreamableHttpMcpTransport } from "alepha/mcp";
import { AlephaServer } from "alepha/server";

run(
  Alepha.create()
    .with(AlephaServer)
    .with(AlephaMcp)
    .with(StreamableHttpMcpTransport)
    .with(TaskTools)
    .with(Resources)
    .with(Prompts),
);
```

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
import { $module } from "alepha";
import { StreamableHttpMcpTransport } from "alepha/mcp";

export const MyAppMcp = $module({
  name: "myapp.mcp",
  services: [StreamableHttpMcpTransport, TaskTools, ProjectTools, Resources],
});
```

Then register the module alongside your other modules:

```typescript
run(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
class PostTools {
  protected posts = $repository(postEntity);
  protected markdown = $inject(MarkdownProvider);

  post_create = $tool({
    description: "Create a new blog post.",
    schema: {
      params: z.object({
        title: z.text(),
        content: z.text({ description: "Markdown content" }),
        tags: z.array(z.text()).optional(),
      }),
    },
    handler: async ({ params }) => {
      const html = this.markdown.render(params.content);
      return await this.posts.create({
        title: params.title,
        content: params.content,
        contentHtml: html,
        tags: params.tags ?? [],
      });
    },
  });
}
```

## 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
schema: {
  params: z.object({
    project: z.integer().describe("Project ID").optional(),
    project_name: z.text({ description: "Case-insensitive project name" }).optional(),
    limit: z.integer()
      .min(1)
      .max(100)
      .describe("Max results to return (default: 20)")
      .optional(),
  }),
}
```

Extract shared schemas to keep tool definitions clean:

```typescript
// schemas/common.ts
export const projectParamsSchema = z.object({
  project: z.integer().describe("Project ID").optional(),
  project_name: z
    .text({ description: "Project name (case-insensitive)" })
    .optional(),
});

// tools/TaskTools.ts
import { projectParamsSchema } from "../schemas/common.ts";

task_list = $tool({
  description: "List tasks for a project.",
  schema: {
    params: projectParamsSchema.extend({
      status: z.enum(["new", "accepted", "completed"]).optional(),
    }),
  },
  handler: async ({ params }) => {
    /* ... */
  },
});
```

## 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
import { $secure } from "alepha/security";

task_list = $tool({
  description: "List the caller's tasks.",
  use: [$secure({ permissions: ["tasks:read"] })],
  handler: async ({ user }) => {
    return this.tasks.findMany({ where: { ownerId: user.id } });
  },
});
```

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](#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
import { StreamableHttpMcpTransport } from "alepha/mcp";

class MyMcpTransport extends StreamableHttpMcpTransport {
  protected buildContext(request: any) {
    return {
      ...super.buildContext(request),
      data: { user: request.user, projectId: request.headers["x-project-id"] },
    };
  }
}

alepha.with({ provide: StreamableHttpMcpTransport, use: MyMcpTransport });
```

A request in the modern protocol (2026-07-28, see
[Protocol revisions](#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
import { NotFoundError } from "alepha/server";

handler: async ({ params }) => {
  const project = await this.projects.findById(params.id);
  if (!project) {
    throw new NotFoundError(`Project ${params.id} not found`);
  }
  return project;
};
```

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
search = $tool({
  description: "Full-text search across every project.",
  schema: { params: z.object({ q: z.text() }) },
  timeout: [30, "seconds"],
  handler: async ({ params }) =>
    this.index.search(params.q, this.alepha.context.signal),
});
```

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
import { mcpToolOptions } from "alepha/mcp";

alepha.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
import { mcpStreamableHttpOptions } from "alepha/mcp";

alepha.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
import { Alepha, run } from "alepha";
import { AlephaMcp, StdioMcpTransport } from "alepha/mcp";

run(Alepha.create().with(AlephaMcp).with(StdioMcpTransport).with(MyTools));
```

Then point the client at the built binary:

```json
{
  "mcpServers": {
    "my-app": {
      "command": "node",
      "args": ["/path/to/my-app/dist/index.node.js"],
      "env": { "DATABASE_URL": "..." }
    }
  }
}
```

**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.

```mermaid
flowchart TD
  R[Incoming request] --> I{initialize?}
  I -->|yes| L[Served as legacy]
  I -->|no| M{protocolVersions holds<br/>a modern version?}
  M -->|yes| V{Non-legacy version in _meta<br/>or MCP-Protocol-Version?}
  V -->|yes| S{Supported?}
  S -->|yes| MO[Served as modern]
  S -->|no| U[JSON-RPC -32022]
  V -->|no| H{MCP-Protocol-Version header<br/>outside protocolVersions?}
  M -->|no| H
  H -->|no, or no header| L
  H -->|yes| B[Plain 400<br/>client 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
import { LEGACY_PROTOCOL_VERSIONS, McpServerProvider } from "alepha/mcp";

alepha.inject(McpServerProvider).protocolVersions = [
  ...LEGACY_PROTOCOL_VERSIONS,
];
```

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
const mcp = alepha.inject(McpServerProvider);
mcp.listCache = { ttlMs: 60_000, cacheScope: "private" }; // lists filtered per user
mcp.discoverCache = { ttlMs: 3_600_000, cacheScope: "public" };

changelog = $resource({
  uri: "docs://changelog",
  cache: { ttlMs: 600_000, cacheScope: "public" }, // same for everyone
  handler: async () => ({ text: await this.changelog.render() }),
});
```

`$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
index_repo = $tool({
  description: "Index every file in the repository.",
  handler: async ({ context }) => {
    const files = await this.files.list();
    for (const [i, file] of files.entries()) {
      await this.index(file);
      context?.reportProgress?.(i + 1, files.length, `Indexed ${file.name}`);
    }
    return { indexed: files.length };
  },
});
```

`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
alepha.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
toolUsage = $hook({
  on: "mcp:tool:call:end",
  handler: ({ name, outcome }) => this.metrics.count(name, outcome),
});
```

## 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
```
