DEV Community

Serik Shaikamalov
Serik Shaikamalov

Posted on

I turned my Hono API into an MCP server by changing one import

I have a travel-agency backend on Hono: tours, bookings, payments. I wanted AI agents to use it too, so that a manager could tell Claude "find March tours under $2000 and book the first one for Ali Aliyev" and Claude would call the right endpoints itself.

The standard way to do that is MCP: a server tells the agent which tools exist, what they do and which parameters they take, as JSON Schema.

The usual approach is to write that server next to your API and describe every endpoint a second time:

server.registerTool('search_tours', {
  description: 'Search tours',
  inputSchema: { month: z.number(), maxPrice: z.number().optional() },
}, async (args) => { /* call the same logic as GET /tours again */ })
Enter fullscreen mode Exit fullscreen mode

But my routes already have those schemas:

app.get('/tours',
  zValidator('query', z.object({
    month: z.coerce.number().min(1).max(12).describe('Departure month'),
    maxPrice: z.coerce.number().optional(),
  })),
  (c) => c.json(searchTours(c.req.valid('query'))),
)
Enter fullscreen mode Exit fullscreen mode

Method, path, parameters, types, even descriptions: everything an agent needs is already there. So I built hono-mcp-bridge:

- import { zValidator } from '@hono/zod-validator'
+ import { zValidator, mountMcp } from 'hono-mcp-bridge'

+ mountMcp(app)   // POST /mcp: every route is now a tool
Enter fullscreen mode Exit fullscreen mode

How it works

  1. A drop-in zValidator. It calls the real @hono/zod-validator and attaches the schema to the returned middleware with a symbol. Same signature, same types, so c.req.valid('query') is still fully typed.
  2. Walk app.routes. Hono keeps every handler as { method, path, handler }. Group by route, read the schemas back, convert them with Zod 4's z.toJSONSchema. GET /tours/:id becomes get_tours_by_id with input { params, query, body }.
  3. A tool call is a real request. Arguments become a Request passed to app.fetch(). Validation, auth middleware (the Authorization and Cookie headers are forwarded), logging and rate limits all apply. If the agent sends bad input, it gets your zod 400 back as isError: true, reads it and retries.
  4. Transport. About 150 lines of dependency-free JSON-RPC implementing MCP's Streamable HTTP in stateless JSON mode. The test suite connects with the official @modelcontextprotocol/sdk client to check compatibility.

Live

claude mcp add --transport http tours http://localhost:3000/mcp
Enter fullscreen mode Exit fullscreen mode

Find March tours under $2000 and book the first one for Ali Aliyev, +77001234567

→ get_tours      {"query": {"month": 3, "maxPrice": 2000}}
← {"tours":[{"id":1,"title":"Umrah, 10 days","price":1890}]}
→ post_bookings  {"body": {"tourId": 1, "fullName": "Ali Aliyev", "phone": "+77001234567"}}
← {"bookingId":"20aa50cd-...", ...}
Enter fullscreen mode Exit fullscreen mode

Security

Agents act with the credentials of whoever connected them. Use include, exclude or methods: ['GET'] to limit what is exposed, hide single routes with describeTool({ hidden: true }), and set allowedOrigins when serving on localhost. Your API still decides who can do what.

Compared to

  • hono-mcp-server: wrap each route in registerTool() with its own schema.
  • hono-gql-mcp: one GraphQL tool derived from @hono/zod-openapi.
  • @hono/mcp: transport only.
  • fastapi-mcp does the same job for Python and FastAPI.

Limitations and roadmap

No SSE or server-initiated messages yet, zod v4 only. Next up: @hono/zod-openapi, Valibot and ArkType via Standard Schema, and outputSchema.

npm i hono-mcp-bridge
Enter fullscreen mode Exit fullscreen mode

GitHub: https://github.com/serikshaikamalov/hono-mcp-bridge. Issues and PRs welcome. I'd love to hear how you handle permissions when exposing your API to agents.

Top comments (4)

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

The part that decides whether this works is the description on each schema. The model picks a tool from its name and description, so a route with a vague zod description becomes a tool it never calls.

Collapse
 
serikshaikamalov profile image
Serik Shaikamalov •

You're right, and it's the part the library can't automate: it removes the duplicated schemas, but not the need to explain when a tool should be used. Right now a route without describeTool() falls back to GET /tours/:id, which is exactly the "never called" case. Next release will warn on startup about tools without descriptions and add a requireDescription option to hide them. Thanks, this is a good push.

Collapse
 
serikshaikamalov profile image
Serik Shaikamalov •

The preview:

Collapse
 
raju_dandigam profile image
Raju Dandigam •

The drop-in zValidator approach is neat because it keeps validation and auth middleware on the same Hono path instead of maintaining a parallel MCP implementation. One boundary I'd test hard is discovery: does tools/list filter by the connected user's effective permissions, or can an agent see a booking tool it will never be authorized to call? A per-principal tool list plus a denied-call trace would make permission debugging much clearer.