AbsoluteJS

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.

TS
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):

MethodRouteDescription
POST/mcpThe 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/mcpReturns 405.
No standalone server-initiated stream.
DELETE/mcpEnd an elicitation session (204).
405 when the endpoint is sessionless — the default, with elicitation off.
GET/.well-known/oauth-protected-resource/mcpRFC 9728 protected-resource metadata.
Per RFC 9728 §3 the endpoint path is inserted after the well-known segment.
GET/.well-known/oauth-protected-resourceThe 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.

1
YouAuthorize
config.authorize(request) resolves the request into a caller (plus its scopes), or returns the reason for the 401.
2
PackageValidate
The body must be one JSON-RPC 2.0 message. JSON-RPC batching is rejected — the 2025-06-18 protocol revision dropped it.
3
PackageDispatch
The method routes to tools, prompts, or resources. Notifications (no id) get a bare 202; unknown methods get method-not-found.
4
PackageRespond
Every reply is a complete HTTP Response — a plain JSON body unless a mayElicit tool is streaming its question over SSE.

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:

initializeNegotiates the protocol version, advertises capabilities derived from the config, and returns serverInfo + instructions.
pingReturns an empty result.
tools/listThe tools visible to this caller (scope-gated tools filtered out), paginated with an opaque cursor.
tools/callRuns one tool through beforeCall, the handler, and onCall.
prompts/listprompts configuredThe prompt definitions, paginated.
prompts/getprompts configuredBuilds one prompt as a single user text message.
resources/listresources configuredThe resources visible to this caller, paginated.
resources/readresources configuredReads one resource by uri.
Batching is rejected, not half-supported
The 2025-06-18 protocol revision dropped JSON-RPC batching, so an array body is refused with an invalid-request error rather than partially processed.

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

BASH
# 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:

Missing bearer tokenrejection reasonBearer header present
Invalid tokenSignature valid (via your verify)
Not an access tokentoken_use === "access"
Wrong issuerIssuer matches
Token expiredNot expired
Token lacks the <scope> scopeHolds requiredScope, when set
Token has no subjectHas a subject
The reason is safe to surface
On success 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:

descriptionWhat the tool does, shown to the model on tools/list.
inputSchemaA JSON Schema object for the arguments.
handler(args, context)Returns a bare string, an array of content blocks, or a full McpToolResult.
annotationsoptionalMCP behaviour hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title), passed straight through to the client.
outputSchemaoptionalJSON Schema for structuredContent, advertised on tools/list.
scopeoptionalThe tool is only listed and callable when the caller holds this scope. Fails closed.
mayElicitoptionalMarks a tool that may ask the user a question mid-call — it answers over SSE instead of a plain JSON body.

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{ text, type }
image{ data, mimeType, type }
audio{ data, mimeType, type }
resource_link{ uri, type, name?, description?, mimeType? }
TS
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.

AIToolMap compatible
The tool shape is structurally compatible with @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:

TS
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:

TS
// 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 */
  })
);
One root alias per app
Only one endpoint per app should set serveRootMetadata — it claims the un-suffixed /.well-known/oauth-protected-resource alias.