Elysia MCP Adapterelysia-mcp-adapter
Getting Started

Quick Start

Expose a typed Elysia route as an MCP tool in a few minutes.

Create a route-backed tool

import { mcp } from '@mwillbanks/elysia-mcp-adapter'
import { Elysia, t } from 'elysia'

const app = new Elysia()
  .use(
    mcp({
      server: {
        name: 'users-api',
        version: '1.0.0',
        title: 'Users API'
      }
    })
  )
  .get(
    '/users/:id',
    ({ params }) => ({ id: params.id, displayName: 'Ada' }),
    {
      params: t.Object({ id: t.String() }),
      response: t.Object({
        id: t.String(),
        displayName: t.String()
      }),
      detail: {
        operationId: 'users.get',
        summary: 'Get a user'
      }
    }
  )
  .listen(3000)

The route is discoverable as the users.get tool because detail.operationId supplies a stable name. Its input uses the default envelope shape:

{
  "params": { "id": "user_123" }
}

Call it over MCP

Send JSON-RPC to the default endpoint:

curl http://localhost:3000/mcp \
  --header 'content-type: application/json' \
  --data '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "users.get",
      "arguments": { "params": { "id": "user_123" } }
    }
  }'

The adapter constructs GET /users/user_123, calls app.handle() with the internal request, and marshals the Elysia response into MCP content.

Make exposure explicit

Use route-level mcp metadata when you want to override discovery:

.get('/orders/:id', getOrder, {
  params: t.Object({ id: t.String() }),
  mcp: {
    expose: true,
    name: 'orders.get',
    title: 'Get an order',
    description: 'Fetch an order by identifier.',
    annotations: {
      readOnlyHint: true,
      idempotentHint: true
    }
  }
})

Next, learn how naming, filtering, schemas, and inputs work in Route-backed tools.

On this page