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
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 }))
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 }
}
{
"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 }
}
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',
})
-
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.
toolscomes from anextractRouteToolscall at startup, so a request only filters and registers. -
Reads can be resources. Write routes become tools, and
registerRouteResourcesservesGETroutes 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.
- Code: github.com/Max-Health-Inc/elysia-mcp (Apache-2.0)
- npm:
@maxhealth.tech/elysia-mcp - HTTP edge:
@maxhealth.tech/mcp-http
Issues and PRs are welcome.
Top comments (1)
wow this was eye opening, thank you so much