DEV Community

Maximilian Nussbaumer
Maximilian Nussbaumer

Posted on

Your Elysia routes are already an MCP server

Every MCP server tutorial starts the same way. You write a tool name, a description, an input schema and a handler. Then you look at your API and notice it already has all four, one per route.

We ran into this building Proxy Smart, a SMART on FHIR authorization proxy whose admin API runs on Elysia and Bun. Declaring every admin route a second time as an MCP tool, and keeping both in step forever, was a bill we did not want to pay.

Our first candidate was our own MCP Generator, which turns an OpenAPI spec into a FastMCP server. It would have worked, but it generates Python, so the MCP server would have run as one more container beside a TypeScript backend.

So we built a bridge inside the backend instead. @maxhealth.tech/elysia-mcp reads the route table of the running Elysia app and hands the tools to @maxhealth.tech/mcp-http, our TypeScript MCP HTTP layer. One process, one language, and the routes stay the single source of truth: a route added tomorrow is a tool tomorrow. It is Apache-2.0 and on npm.

Quick start

Install the package next to its three peers. The host picks their versions, and it needs the v2 MCP SDK (@modelcontextprotocol/server 2.0 or newer).

bun add @maxhealth.tech/elysia-mcp elysia @sinclair/typebox @modelcontextprotocol/server
Enter fullscreen mode Exit fullscreen mode

Here is a small roles API, served as an MCP endpoint at /mcp:

import { Elysia, t } from 'elysia'
import { createMcpHandler, hostHeaderValidationResponse, localhostAllowedHostnames } from '@modelcontextprotocol/server'
import { mcp, toMcpServer, bearerToken } from '@maxhealth.tech/elysia-mcp'

const roles = new Map<string, { name: string }>()

const app = new Elysia()
  .use(mcp())
  .get('/admin/roles', () => [...roles.values()], {
    detail: { summary: 'List roles', description: 'Every realm role.' },
  })
  .post('/admin/roles', ({ body }) => {
    roles.set(body.name, body)
    return body
  }, { body: t.Object({ name: t.String() }) })
  .delete('/admin/roles/:roleName', ({ params }) => roles.delete(params.roleName), { mcp: false })

const handler = createMcpHandler(({ requestInfo }) =>
  toMcpServer(app, { authToken: bearerToken(requestInfo), serverInfo: { name: 'roles', version: '1.0.0' } }))

app.all('/mcp', ({ request, body }) =>
  hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? handler.fetch(request, { parsedBody: body }))
Enter fullscreen mode Exit fullscreen mode

tools/list now answers with two tools. The delete route opted out with mcp: false:

{
  "name": "get_admin_roles",
  "title": "List roles",
  "description": "Every realm role.",
  "annotations": { "readOnlyHint": true, "idempotentHint": true, "openWorldHint": false }
}
Enter fullscreen mode Exit fullscreen mode
{
  "name": "create_admin_roles",
  "description": "Create a new admin roles. (Admin only)",
  "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] },
  "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }
}
Enter fullscreen mode Exit fullscreen mode

Calling create_admin_roles with { "name": "auditor" } returns the body as a text block and as structuredContent. Calling it without name never reaches your handler: the arguments are checked against the route's own TypeBox schema first and come back as an isError result.

Two lines in that mount are easy to miss. The SDK's createMcpHandler does not check the Host header, so the route does, against DNS rebinding; list your real hostnames there when you deploy. And Elysia may already have read the request body, so it is handed over as parsedBody.

Choosing what becomes a tool

.use(mcp()) adds one typed route option:

Option Effect
mcp: true Exposed, even outside the prefixes
mcp: false Never exposed
mcp: { name, title, description, public } Exposed, with any of these overriding what is derived

Routes without the option are selected by path prefix, /admin/ and /api/ by default, so nothing becomes a tool merely by existing. Pass prefixes: [] and only marked routes are exposed.

A route with OpenAPI docs needs nothing extra. detail.summary becomes the tool's title and detail.description its description. detail.operationId names the tool, and detail.hide: true keeps a route out. The mcp option wins over all of them. Only undocumented routes fall back to a description generated from the name.

How a route becomes a tool

Names

Without an operationId or an explicit name, the tool name is the flattened path with a verb in front:

Method Path Tool name
GET /admin/healthcare-users get_admin_healthcare-users
GET /admin/healthcare-users/:userId get_admin_healthcare-users_userId
POST /admin/healthcare-users create_admin_healthcare-users
PUT /admin/smart-apps/:clientId update_admin_smart-apps_clientId
DELETE /admin/roles/:roleName delete_admin_roles_roleName

Clients cap tool names at 64 characters. A longer name is cut to fit and gets a seven-character FNV-1a digest of the full name as a suffix. FNV-1a gives the same value on every runtime, which matters: a tool name that shifts between deploys breaks prompts people have already saved. If two routes still collide, the second gets a digest of METHOD path so it cannot silently overwrite the first.

Annotations

REST semantics map cleanly onto MCP's tool annotations:

Method readOnly destructive idempotent
GET yes no yes
PUT no no yes
PATCH no no no
POST no no no
DELETE no yes yes

openWorldHint is false for every route, because the tools act on your own API. Clients use these hints to ask for confirmation before a delete_* call. They are not a security boundary.

Schemas

TypeBox schemas are already JSON Schema with some Symbol metadata attached, so converting one for the SDK is a JSON round trip followed by the SDK's own fromJsonSchema. There is no field-by-field translation to drift.

An MCP client sends one flat argument object and has no idea which value travels in the URL. The package folds path params into the body (or query) schema for that reason. Path params win on collisions and are always required.

The success entry of a route's response becomes the tool's outputSchema. Array roots work too, since list routes usually declare t.Array(...).

Running it behind auth

In production you build one McpServer per request, from a validated token. We put @maxhealth.tech/mcp-http in front for OAuth discovery, CORS and observability, and register the routes with one call:

registerRouteTools(server, app, {
  tools,
  authToken: token,
  filter: (name, meta) => !meta.readOnly && (meta.public || roles.includes('admin')),
  textFormat: 'auto',
})
Enter fullscreen mode Exit fullscreen mode
  • Tools exist per caller. The filter runs against this request's token, so a caller without the admin role never sees an admin tool in tools/list.
  • The route table is read once. tools comes from an extractRouteTools call at startup, so a request only filters and registers.
  • Reads can be resources. Write routes become tools, and registerRouteResources serves GET routes as MCP resources and URI templates.

Smaller text blocks with textFormat: 'auto'

List endpoints are where an agent's context goes. With textFormat: 'auto', each payload is encoded as compact JSON and as TOON, and the shorter text wins. Measured on our own responses with gpt-tokenizer:

Response JSON tokens TOON tokens Change
Roles list (30, flat) 894 515 −42%
SMART scopes (40, flat) 973 700 −28%
Healthcare users (nested) 3433 3808 +11%
Single object 78 85 +9%

TOON loses on nested data, which is why the package measures both instead of guessing from the shape. 'auto' never produces a longer text block than plain JSON, and structuredContent always stays JSON.

Optional UI with MCP Apps

Pass a view and the tool result gains a rendered UI in structuredContent, which an MCP Apps host draws in a sandboxed iframe. The model still reads the text block, so the UI costs no tokens. The /prefab subpath ships a default view: lists become searchable tables, single records become detail cards. A view that throws leaves the result untouched, and a server given a view stops advertising outputSchema, since the view no longer matches it.

Try it

The package pins no MCP protocol version. The SDK negotiates it on the wire, so supporting a newer spec is an SDK bump on your side. Introspection works on Elysia 1.2 and later and reads the same route table on the Elysia 2.0 beta.

If you already have an Elysia API with TypeBox schemas, you are about a dozen lines away from an MCP server that respects your guards. We would love to hear where the generated names or descriptions fall short for your routes, and which Elysia setups break the introspection.

Issues and PRs are welcome.

Top comments (1)

Collapse
 
julian_mosbacher_8278cb1e profile image
Julian Mosbacher •

wow this was eye opening, thank you so much