#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.
#Quick Start
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.
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 }. Returns the result. |
name |
string |
Override the tool name. Defaults to the property key. |
Parameters and results are validated automatically. If validation fails, the client receives a JSON-RPC error.
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.
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.
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 |
Returns { text } for text content or { blob } for binary. |
name |
string |
Display name. Defaults to the property key. |
#$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:
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. |
#$prompt: Message Templates
Prompts define reusable conversation templates with typed arguments.
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. |
#Wiring It Up
Register the AlephaMcp module and your tool/resource/prompt classes:
1import { Alepha, run } from "alepha"; 2import { AlephaServer } from "alepha/server"; 3import { AlephaMcp } from "alepha/mcp"; 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:
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:
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:
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:
- Runtime validation: params are validated before your handler runs, results are validated before being sent back
- 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:
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:
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});
#Context
Every handler receives an optional context with HTTP headers and custom data. Use it for authentication or request-scoped metadata:
1task_list = $tool({ 2 description: "List user tasks.", 3 handler: async ({ params, context }) => { 4 const auth = context?.headers?.authorization; 5 if (!auth?.toString().startsWith("Bearer ")) { 6 throw new McpUnauthorizedError("Missing authentication"); 7 } 8 // ... 9 },10});
context.data carries whatever the transport put there. By default that is the
authenticated user (request.user), so a tool can read the caller without
resolving it again:
1task_list = $tool({ 2 description: "List the caller's tasks.", 3 handler: async ({ context }) => { 4 const user = context?.data as UserAccountToken | undefined; 5 if (!user) { 6 throw new McpUnauthorizedError("Authentication required."); 7 } 8 return this.tasks.findMany({ where: { ownerId: user.id } }); 9 },10});
To carry anything else, such as a project scope or request id, override
buildContext on the transport and register the subclass:
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:
1import { McpUnauthorizedError, McpForbiddenError } from "alepha/mcp"; 2 3handler: async ({ params, context }) => { 4 if (!context?.headers?.authorization) { 5 throw new McpUnauthorizedError("Missing token"); 6 } 7 const project = await this.projects.findById(params.id); 8 if (!project) { 9 throw new NotFoundError(`Project ${params.id} not found`);10 }11 return project;12};
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.
#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 returnapplication/jsonGET /mcpandDELETE /mcp: return405 Method Not Allowed(no standalone SSE stream, no session to end)
The path is configurable (keep it outside /api, which belongs to the $action dispatcher):
1import { mcpStreamableHttpOptions } from "alepha/mcp";2 3alepha.store.mut(mcpStreamableHttpOptions, (o) => ({ ...o, path: "/my-mcp" }));
#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:
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:
1{2 "mcpServers": {3 "my-app": {4 "command": "node",5 "args": ["/path/to/my-app/dist/index.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 inparams._meta, and a client may callserver/discoverfirst to learn what the server supports. - Legacy, 2025-11-25 back to 2024-11-05 (
LEGACY_PROTOCOL_VERSIONS). The client opens withinitializeand 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.
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) onserver/discover, the four lists andresources/read, described below. - Strict header checks on HTTP:
MCP-Protocol-Version,Mcp-Method, andMcp-Nameontools/call,resources/readandprompts/get, must be present and must match the body (Mcp-Namemay be sent as=?base64?...?=). A missing or disagreeing header is400with-32020. - HTTP statuses for protocol failures: an unsupported version is
400with-32022listing what is supported, an unknown method (pingincluded, which 2026-07-28 removed) is404with-32601. Every other JSON-RPC error, and every legacy error, stays200.
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:
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:
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.
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:
1alepha.inject(McpServerProvider).pageSize = 25;
#Naming Convention
Use entity_action for tool names (snake_case with underscore separator):
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:
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