MCP Endpoints
Serve a remote Model Context Protocol endpoint — streamable HTTP, stateless — from a tool/prompt/resource registry. You supply which tools to expose and how to authorize a request into a caller; @absolutejs/mcp owns the JSON-RPC protocol, protocol-version negotiation, RFC 9728 discovery metadata, and the 401 challenge that lets a client find your authorization server. Nothing here depends on a model.
#Define an Endpoint
mcpServer() builds the endpoint as an Elysia plugin — mount it with .use(). The only peer dependency is elysia. Two things are yours: authorize decides who is allowed in, and tools builds the registry for that caller, called once per request.
import { Elysia } from 'elysia';
import { mcpServer, verifyBearer } from '@absolutejs/mcp';
import { verifyJwt } from '@absolutejs/auth'; // or any JWT verifier
type Caller = { userId: string };
const server = new Elysia().use(
mcpServer<Caller>({
path: '/mcp',
issuer: 'https://your.app',
serverInfo: { name: 'your-app', title: 'Your App', version: '1.0.0' },
instructions: 'What the model should know about this server.',
scopesSupported: ['openid', 'mcp'],
serveRootMetadata: true,
// You decide who is allowed in. verifyBearer does the standard
// OAuth access-token checks; add your own (billing, role, MFA)
// on top.
authorize: async (request) => {
const token = await verifyBearer({
request,
issuer: 'https://your.app',
requiredScope: 'mcp',
verify: (jwt) => verifyJwt(jwt, publicJwk)
});
if ('error' in token) return { ok: false, reason: token.error };
return { ok: true, caller: { userId: token.subject } };
},
// Called once per request; build the tools for this caller.
tools: ({ caller }) => buildToolsFor(caller.userId)
})
);That is a complete member endpoint. GET /mcp returns 405, POST /mcp speaks JSON-RPC, and GET /.well-known/oauth-protected-resource[/mcp] serves the discovery metadata.
#Route Surface
One plugin registers the whole surface for its path (here /mcp):
| Method | Route | Description |
|---|---|---|
| POST | /mcp | The JSON-RPC endpoint (streamable HTTP). Answers with a plain JSON body, or an SSE stream only when a tool marked mayElicit runs inside a session. |
| GET | /mcp | Returns 405. No standalone server-initiated stream. |
| DELETE | /mcp | End an elicitation session (204). 405 when the endpoint is sessionless — the default, with elicitation off. |
| GET | /.well-known/oauth-protected-resource/mcp | RFC 9728 protected-resource metadata. Per RFC 9728 §3 the endpoint path is inserted after the well-known segment. |
| GET | /.well-known/oauth-protected-resource | The same metadata at the un-suffixed root alias. Served only when serveRootMetadata is set — some clients probe the root. |
#Transport & Statelessness
The transport is streamable HTTP: every request is a self-contained POST, and every reply is a complete HTTP Response. Leave elicitation off — the default — and the endpoint holds no session state at all: no Mcp-Session-Id, no standalone SSE stream, nothing to replicate across instances.
Protocol negotiation is automatic: the endpoint accepts the versions in supportedProtocols (defaults 2025-06-18, 2025-03-26, 2024-11-05), the first being preferred — a request for an unknown version falls back to it. The dispatcher handles:
#Authorization & Discovery
authorize(request) runs before anything else on every POST and returns { ok: true, caller, scopes? } or { ok: false, reason }. On failure the package emits the 401 with an RFC 9728 WWW-Authenticate challenge pointing at the protected-resource metadata, so the client can discover your authorization server on its own:
# An unauthorized POST gets the RFC 9728 challenge...
$ curl -i -X POST https://your.app/mcp
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://your.app/.well-known/oauth-protected-resource/mcp"
# ...and the metadata document points the client at your
# authorization server.
$ curl https://your.app/.well-known/oauth-protected-resource/mcp
{
"authorization_servers": ["https://your.app"],
"resource": "https://your.app/mcp",
"scopes_supported": ["openid", "mcp"]
}The package never verifies tokens itself — any authorization server (or none) works. verifyBearer() is the convenience your authorize can call for the standard OAuth access-token checks; you supply the signature verify function, and it layers the claim checks every MCP endpoint needs:
token_use === "access"verifyBearer returns the decoded payload, the parsed scopes, and the subject. On failure it returns a reason string that is safe to expose in the 401 — it never says why a signature failed.#Tools & Tool Maps
tools is a factory, not a static list: it receives { caller, meta } and returns an McpToolRegistry — a record of named McpTool entries built for that caller. Each tool carries:
A bare string return is the common case — it is wrapped as one text block. Rich results mix four content block types, plus optional structuredContent validated against the tool's outputSchema:
{ text, type }{ data, mimeType, type }{ data, mimeType, type }{ uri, type, name?, description?, mimeType? }tools: ({ caller }) => ({
get_report: {
description: 'Fetch the latest report.',
inputSchema: { type: 'object' },
// Behaviour hints, passed straight through on tools/list.
annotations: { readOnlyHint: true, title: 'Get report' },
// JSON Schema for structuredContent, advertised on tools/list.
outputSchema: {
properties: { revenue: { type: 'number' } },
type: 'object'
},
// A handler may return a bare string (wrapped as one text block),
// an array of content blocks, or a full McpToolResult.
handler: async () => [
{ text: 'Q2 revenue is up 14%.', type: 'text' },
{ data: chartBase64, mimeType: 'image/png', type: 'image' },
{ name: 'Full report', type: 'resource_link', uri: 'report://q2' }
]
}
})tools/list, prompts/list, and resources/list paginate with an opaque cursor — listPageSize sets the page size (default 50), and clients follow nextCursor until the list ends.
@absolutejs/ai's AIToolMap, so an AI tool registry serves over MCP without conversion — but any typed tool registry works.#Framework-Agnostic Handler
The Elysia plugin is a thin wrapper over a transport-agnostic core. createMcpHandler() exposes the same core directly, so the Elysia and raw-handler paths can never diverge:
import { createMcpHandler } from '@absolutejs/mcp';
// The same config, no Elysia: a single (request) => Response | null
// over web-standard Request/Response. Mount it on Bun.serve, a Hono
// route, a Next.js route handler, or Cloudflare Workers. It returns
// null when the request is not for an MCP route, so you can fall
// through to your own routing.
const mcp = createMcpHandler(config);
Bun.serve({
fetch: async (request) =>
(await mcp(request)) ?? new Response('Not found', { status: 404 })
});#Multiple Endpoints
mcpServer is per-endpoint, so a second, stricter endpoint is just a second .use() with a different path and its own config:
// mcpServer is per-endpoint, so an admin console is the same call
// with a different scope, a stricter authorize (role + MFA + a kill
// switch, re-checked live), a rate-limit beforeCall, and an audit
// onCall.
app.use(mcpServer({ path: '/mcp' /* member */ })).use(
mcpServer({
path: '/mcp/admin',
scopesSupported: ['openid', 'mcp:admin'] /* stricter */
})
);serveRootMetadata — it claims the un-suffixed /.well-known/oauth-protected-resource alias.