hono-apcore
Enables turning a Hono app into MCP tools and OpenAI-compatible function definitions, either by declaring tools explicitly or by scanning existing routes.
README
hono-apcore
Hono adapter for the apcore AI-Perceivable module ecosystem. Turn a Hono app into MCP tools and OpenAI-compatible function definitions — either by declaring tools explicitly, or by scanning the routes you already have.
Features
- Two ways in — declare tools with
defineTool()/defineToolset(), or scan your existing routes with zero code changes - Route replay — a scanned route becomes a module that calls back through
app.request(), so middleware, validators, and error handlers all still run - One port —
mountMcp()serves the MCP endpoint, the Tool Explorer, and/healthfrom the same Hono app - Annotation inference —
GET→ readonly + cacheable,PUT→ idempotent,DELETE→ destructive (RFC 9110 safe-method semantics) - Multi-schema — TypeBox, Zod 3, Zod 4, and plain JSON Schema, auto-detected through a priority chain
- Context, ACL, and identity — the
apcore()middleware builds a per-request apcoreContextwith W3C trace propagation, so ACL rules govern your routes too - Runtime-agnostic core —
apcore-mcp,apcore-cli, andapcore-a2aare optional peers loaded lazily, so importinghono-apcorenever dragsnode:httpinto an edge build - CLI —
hono-apcore scan | serve | exportworks against a plain Hono app - YAML bindings — register modules declaratively, without touching source
Installation
npm install hono-apcore hono
Optional peers, installed only for the surfaces you use:
npm install apcore-mcp @modelcontextprotocol/sdk # MCP server + Tool Explorer
npm install @hono/node-server # mountMcp() on the Node runtime
npm install apcore-cli # CLI surface
npm install apcore-a2a # A2A agent surface
npm install @sinclair/typebox # TypeBox schemas (recommended)
npm install zod # Zod schemas
Requirements: Node.js >= 18, Hono >= 4 (tested with Hono 4.13).
Quick start
1. Declare some tools
// todo.tools.ts
import { Type } from '@sinclair/typebox';
import { defineToolset } from 'hono-apcore';
export const todoTools = defineToolset({
namespace: 'todo',
description: 'Todo list management',
tags: ['todo'],
tools: {
list: {
description: 'List all todos, optionally filtered by status',
inputSchema: Type.Object({ done: Type.Optional(Type.Boolean()) }),
annotations: { readonly: true, idempotent: true },
handler: (inputs) => ({ todos: store.list(inputs.done as boolean | undefined) }),
},
add: {
description: 'Add a new todo item',
inputSchema: Type.Object({ title: Type.String() }),
annotations: { readonly: false },
handler: (inputs) => ({ todo: store.add(String(inputs.title)) }),
},
},
});
2. Wire it into the app
// app.ts
import { Hono } from 'hono';
import { apcore, createApcore } from 'hono-apcore';
import { todoTools } from './todo.tools.js';
export const ap = createApcore({
tools: todoTools,
mcp: { name: 'my-app', explorer: true, allowExecute: true },
});
export const app = new Hono();
app.use('*', apcore(ap));
app.get('/todos', (c) => c.json(store.list()));
3. Boot
// main.ts
import { serve } from '@hono/node-server';
import { app, ap } from './app.js';
await ap.init(app); // register tools + scan routes
await ap.mountMcp(app); // mount /mcp, /explorer, /health
serve({ fetch: app.fetch, port: 3000 });
Your app now answers:
- REST at
http://localhost:3000/todos - MCP at
http://localhost:3000/mcp - Tool Explorer at
http://localhost:3000/explorer/
Two ways to expose a capability
defineTool() — explicit tools
The Hono counterpart to NestJS's @ApTool decorator. Hono has no classes or DI
container to decorate, so a tool is a plain object that carries its own
metadata and handler.
import { defineTool } from 'hono-apcore';
const sendEmail = defineTool({
namespace: 'email',
name: 'send', // -> module id "email.send"
description: 'Send an email',
inputSchema: Type.Object({ to: Type.String(), body: Type.String() }),
outputSchema: Type.Object({ messageId: Type.String() }),
annotations: { readonly: false, destructive: false, requiresApproval: true },
tags: ['email'],
params: { to: 'Recipient address' }, // merged into the schema descriptions
handler: async (inputs, context) => mailer.send(inputs, context),
});
| Field | Notes |
|---|---|
id |
Used verbatim. Otherwise "<namespace>.<name>", with name snake-cased |
inputSchema / outputSchema |
TypeBox, Zod, or plain JSON Schema |
annotations |
readonly, destructive, idempotent, requiresApproval, openWorld, streaming, cacheable, … |
params |
Per-parameter prose merged into the input schema. JavaScript cannot read a function's leading comments at run time the way Python reads a docstring, so this is explicit |
handler |
(inputs, context) => result. A non-object result is wrapped as { result } |
Route scanning — zero-intrusion tools
Point the scanner at an app and every route becomes a module that replays it
in-process through app.request():
const ap = createApcore({
routes: {
excludePaths: ['/health', '/mcp*', '/explorer*'],
modulePrefix: 'api',
},
});
await ap.init(app); // -> api.todos.list, api.todos.get, api.todos.create, …
Module IDs come from the path and the HTTP verb:
| Route | Module ID | Inferred annotations |
|---|---|---|
GET /todos |
todos.list |
readonly, cacheable |
GET /todos/:id |
todos.get |
readonly, cacheable |
POST /todos |
todos.create |
— |
PUT /todos/:id |
todos.update |
idempotent |
DELETE /todos/:id |
todos.delete |
destructive |
The generated input schema carries one required string property per path
parameter, plus a free-form query object (GET/DELETE) or body object
(POST/PUT/PATCH). Override any of it per route:
routes: {
overrides: {
'GET /todos': {
id: 'todo.all',
description: 'Every todo, newest first',
inputSchema: Type.Object({ done: Type.Optional(Type.Boolean()) }),
annotations: { readonly: true, idempotent: true },
},
'DELETE /admin/wipe': { skip: true },
},
}
Because execution goes back through app.request(), an AI call runs the same
code path as an HTTP call — auth middleware, validators, error handlers and all.
Identity and W3C trace headers from the apcore Context are forwarded onto the
replayed request.
API reference
createApcore(options)
Returns a HonoApcore — the Registry, the Executor, and every surface hang off
it.
createApcore({
extensionsDir?: string | null, // scanned by Registry.discover()
acl?: ACL, // enforced by the Executor on every call
middleware?: Middleware[], // apcore middleware installed on the Executor
bindings?: string, // YAML bindings file loaded during init()
tools?: ApToolDefinition[], // registered during init()
routes?: RouteScanOptions, // route-scanner configuration
settings?: Partial<ApcoreSettings>, // overrides for the APCORE_* settings
mcp?: ApcoreMcpOptions, // presence enables the MCP surface
cli?: ApcoreCliOptions, // presence enables the CLI surface
a2a?: ApcoreA2aOptions, // presence enables the A2A surface
})
| Method | Description |
|---|---|
init(app?, routeOptions?) |
Discover, register tools and bindings, scan routes, start standalone surfaces. Idempotent |
ready() |
Await an in-flight init() |
registerTool(tool) / registerTools(tools) |
Register tool definitions at run time |
registerMethod(opts) / registerObject(opts) |
Register the methods of a plain service object |
scanRoutes(app, opts?) |
Scan and register an app's routes |
routeOptions |
The merged route-scan options this instance would use |
loadBindings(path?, resolver?) |
Load a YAML bindings file |
mountMcp(app, opts?) |
Mount /mcp, the Explorer, and /health into the app |
toOpenaiTools(opts?) |
OpenAI-compatible function definitions |
close() |
Shut down the MCP and A2A surfaces |
apcore(instance | options, middlewareOptions?)
Hono middleware that puts the instance and a per-request apcore Context on the
Hono context.
app.use('*', apcore(ap));
app.get('/orders', async (c) =>
c.json(await getApcore(c).executor.call('orders.list', {}, getApcoreContext(c))),
);
The variable map is augmented, so c.get('apcore') and c.get('apcoreContext')
are typed too. Pass { skipContext: true } on routes that never call modules,
or { contextFactory } to plug in real authentication.
HonoContextFactory
Builds the apcore Context from a Hono context, a Request, or bare Headers.
Identity resolution, in order: x-user-id → Authorization: Bearer … (identity
id "bearer") → a bare x-roles header (a demo shortcut) → anonymous. A
traceparent header supplies the trace id; x-correlation-id (or
x-request-id) lands in context.data.
new HonoContextFactory({
resolveIdentity: (headers) => identityFromSession(headers), // wins over the above
data: (headers) => ({ tenant: headers.get('x-tenant') }),
});
MCP
ApcoreMcpService runs the MCP server two ways.
Embedded — one process, one port:
await ap.mountMcp(app, { endpoint: '/mcp', explorer: true, allowExecute: true });
This needs the raw Node request and response objects that @hono/node-server
exposes on c.env, so it is Node-only; a mounted handler on another runtime
answers 501 with that explanation. endpoint must be the path as the HTTP
server sees it — include the prefix if the app sits under a basePath.
Standalone — a separate port, or stdio for a CLI-launched server:
createApcore({ mcp: { transport: 'streamable-http', host: '0.0.0.0', port: 8000 } });
// init() starts it, because `transport` was set explicitly
Key MCP options:
| Field | Type | Description |
|---|---|---|
transport |
'stdio' | 'streamable-http' | 'sse' |
Standalone transport. Setting it makes init() start the server |
host / port |
string / number |
Bind address for HTTP transports |
name / version |
string |
Server identity |
explorer / explorerPrefix / allowExecute |
Tool Explorer web UI | |
authenticator / requireAuth / exemptPaths |
JWT or custom auth | |
tags / prefix |
Expose only matching modules | |
validateInputs |
boolean |
Enforce input schemas on every call |
observability |
Metrics + usage middleware and their endpoints | |
outputFormat / outputFormatter / redactOutput / trace |
Result serialisation | |
approvalHandler / approvalStore / approvalNotify |
Approval gate for destructive tools | |
mcpMiddleware / mcpAcl |
Extra apcore middleware / ACL for the MCP executor |
Schema adapters
Schemas are auto-detected and converted through a priority chain:
| Adapter | Priority | Input |
|---|---|---|
TypeBoxAdapter |
100 | @sinclair/typebox schemas |
ZodAdapter |
50 | Zod 3 (_def.typeName) and Zod 4 (_zod.def.type) |
JsonSchemaAdapter |
30 | Plain JSON Schema objects |
Detection is structural — neither TypeBox nor Zod is imported at run time — so
whichever the host app installs (or neither) is fine. Register your own with
SchemaExtractor.registerAdapter().
YAML bindings
Register modules without touching source:
bindings:
- module_id: email.send
target: EmailService.send
description: Send an email
input_schema:
type: object
properties:
to: { type: string }
tags: [email, mutate]
annotations:
readonly: false
import { resolverFromObjects } from 'hono-apcore';
await ap.loadBindings('./bindings.yaml', resolverFromObjects({ EmailService: mailer }));
Going the other way, writeBindingsFile() serialises scanned modules back out —
which is what hono-apcore scan --format yaml does.
CLI
hono-apcore scan ./src/app.ts # print the modules a scan would produce
hono-apcore scan ./src/app.ts --format yaml --out bindings.yaml
hono-apcore serve ./src/app.ts --transport http --port 8000 --explorer
hono-apcore export ./src/app.ts --out tools.json
The entry is path[:export]; the export defaults to default, then app. If
the module exports a HonoApcore under any name, its configuration — route
filters, module prefix, MCP options — is honoured, so scan reports exactly the
modules the app itself registers; CLI flags override it. An entry with no
instance still works, so serve runs against an app that has never heard of
apcore. TypeScript entries need a loader:
npx tsx node_modules/.bin/hono-apcore scan ./src/app.ts
Configuration (APCORE_*)
The canonical settings every apcore integration implements, read from the
environment and overridable via settings:
| Variable | Type | Default | Purpose |
|---|---|---|---|
APCORE_ENABLED |
bool | true |
Master switch — false makes init() a no-op |
APCORE_DEBUG |
bool | false |
Verbose logging / introspection |
APCORE_SCANNERS |
list | ["auto"] |
Enabled scanner identifiers |
APCORE_INCLUDE_PATHS |
list | [] |
Route patterns to include (empty = all) |
APCORE_EXCLUDE_PATHS |
list | [] |
Route patterns to exclude |
APCORE_MODULE_PREFIX |
str | "" |
Prefix prepended to generated module IDs |
APCORE_AUTH_ENABLED |
bool | false |
Require auth for MCP/A2A endpoints |
APCORE_AUTH_STRATEGY |
str | "bearer" |
bearer / session / custom |
APCORE_TRANSPORT |
str | "stdio" |
MCP transport: stdio / http / sse |
APCORE_HOST |
str | "0.0.0.0" |
Bind address when the transport is not stdio |
APCORE_PORT |
int | 8808 |
Bind port when the transport is not stdio |
Optional peers are not re-exported
Unlike the NestJS adapter, hono-apcore does not re-export the apcore-mcp
/ apcore-cli / apcore-a2a surfaces. Doing so would make them load eagerly,
and apcore-mcp pulls in node:http — which breaks a Workers, Deno, or Bun
build of an app that never uses the MCP surface. Import those symbols from their
own packages:
import { JWTAuthenticator, getCurrentIdentity } from 'apcore-mcp';
import { createCli } from 'apcore-cli';
import { A2AClient } from 'apcore-a2a';
apcore-js and apcore-toolkit are hard dependencies, so their common
symbols (ACL, Config, registerSysModules, TraceContext, BaseScanner,
formatModules, …) re-export from hono-apcore directly.
Examples
| Example | Shows |
|---|---|
examples/demo |
Full app: hand-written tools and route scanning, JWT, ACL, system modules, Docker |
examples/acl_demo |
Routes governed by apcore ACL — orders.delete for admins only |
pnpm install && pnpm build
cd examples/demo && pnpm install && pnpm dev
Detailed documentation
- Feature overview — architecture and dependency graph
- Tool definition —
defineTool,defineToolset, module IDs - Route scanner — how routes become modules, and what replay costs
- MCP integration — embedded vs standalone, the Node bridge
- Schema extraction — the adapter chain and custom adapters
- Context and ACL — identity, tracing, and governing routes
Scripts
| Command | Description |
|---|---|
pnpm build |
Compile TypeScript |
pnpm dev |
Watch-mode compilation |
pnpm test |
Run the test suite (vitest) |
pnpm test:coverage |
Tests with coverage (90% thresholds) |
pnpm typecheck |
Type-check without emitting |
pnpm lint |
Lint source and tests |
License
Apache-2.0
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.