AI tools & MCP
A procedure marked .ai() (see Router) can also be called by
a model. The functions on this page shape those procedures into tool schemas, run
the model’s tool calls with the same validation and .use() guards as HTTP,
expose them over MCP, blend in tools from external MCP servers, and hold
.ai({ confirm: true }) calls until a person approves them. For the concepts, see
AI & MCP.
import {
toToolset, createToolExecutor, formatTool, toToolSource, mergeToolsets,
mcpToolDefinitions, createMcpBridge, createMcpToolSource,
createApprovals, createInMemoryApprovalStore, isPendingResult,
} from 'dendra'Everything here is stable. The agent loop and context layer built on top of it
(runAgent, lazyToolset, …) are on the Agents page.
Tools
toToolset(router, opts?)
Returns a schema for every .ai() procedure in the router, formatted for one
provider. Procedures without .ai() are left out.
function toToolset(router: object, opts?: { format?: ToolFormat }): unknown[]| Option | Type | Default | Description |
|---|---|---|---|
format | ToolFormat | 'anthropic' | Shape of each tool object. |
For each tool:
- Name is the procedure’s dotted path in the router, the same in every format:
appRouter.notes.createbecomesnotes.create. dendra doesn’t rewrite it. Anthropic and OpenAI only accept names matching^[a-zA-Z0-9_-]+$, so if you send these schemas to a provider yourself, map.to_and back.runAgentandlazyToolsetdo that for you. - Description is
.ai({ description }), else.describe(), else the name. A confirm tool gets this appended:(Needs a person's approval: calling it returns a pending action, not the result.) - Schema is the procedure’s input JSON Schema (the one OpenAPI uses, with its
constraints). A procedure with no
.input()gets{ type: 'object', properties: {} }.
What each format produces for notes.create:
// 'anthropic'
{ name: 'notes.create', description: 'Create a note', input_schema: { /* … */ } }
// 'openai'
{ type: 'function', function: { name: 'notes.create', description: 'Create a note', parameters: { /* … */ } } }
// 'plain' (a PlainTool)
{ name: 'notes.create', description: 'Create a note', inputSchema: { /* … */ } }createToolExecutor(router, ctx, opts?)
Returns a function that runs one tool call by name, the way the model asked for it.
Input is validated and every .use() guard runs with ctx, exactly as over HTTP,
and after-hooks see the call with transport: 'tool'.
function createToolExecutor<Ctx>(
router: object,
ctx: Ctx,
opts?: { approvals?: Approvals<Ctx> },
): (name: string, args: unknown, call?: { signal?: AbortSignal }) => Promise<unknown>const execute = createToolExecutor(appRouter, await createContext(req), { approvals })
const result = await execute('notes.create', { title: 'Hi', body: '…' }, { signal })The model acts with the permissions of the ctx you pass. Build it from the
request, per request; an executor made once at startup with a service ctx gives the
model that service’s rights.
A streaming procedure (.stream()) has its chunks collected: joined into one
string if every chunk is a string, else returned as an array. Collection stops at
the tool’s maxItems (default 1,000) or timeoutMs (default 30 s), set with
.ai(), and the call answers with what arrived. The
handler’s signal then aborts, so a subscription stops too. So a watch exposed
as a tool reads like “what happened in the next 30 seconds”.
call.signal cancels a call: it rejects with the signal’s reason, as fetch
does, and a stream’s handler signal aborts. A unary handler has no signal and
runs on, but the caller stops waiting for it. An already-aborted signal rejects
before the tool runs. runAgent passes its own signal.
| Option | Type | Description |
|---|---|---|
approvals | Approvals<Ctx> | From createApprovals(). Needed to call confirm tools. |
It only throws DendraErrors, so a hand-written agent loop can pass e.message
to the model as is:
| Code | When |
|---|---|
NOT_FOUND | No procedure has that name, or it has no .ai(). Both read Tool 'x' not found, so the model can’t probe for procedures it wasn’t given. |
BAD_REQUEST | Input validation failed (Input validation failed, issues in details). |
FORBIDDEN | A confirm tool was called without approvals. It never runs. |
| any | A DendraError thrown by a guard or the handler passes through. |
INTERNAL | Any other error (a DB driver error, a TypeError). The message is Internal error; the original is logged with console.error. |
For a confirm tool, see Approvals: the call is stored and returns a
PendingResult instead of running. Passing an approvals object that didn’t come
from createApprovals() throws a plain Error.
formatTool(name, description, schema, format)
Shapes a single tool for a provider. toToolset and createMcpToolSource both use
it, so a local procedure and a remote MCP tool look the same to the model. Use it
for tools you define outside the router.
function formatTool(name: string, description: string, schema: JSONSchema, format: ToolFormat): unknownToolFormat
Which provider’s tool shape to produce (see toToolset for each shape).
type ToolFormat = 'anthropic' | 'openai' | 'plain'PlainTool
The 'plain' format.
interface PlainTool {
name: string
description: string
inputSchema: JSONSchema
}Tool sources
A tool source is a set of tool schemas plus the function that runs them. Your
router and each external MCP server are sources; mergeToolsets combines them.
toToolSource(router, ctx, opts?)
Wraps a router and ctx as a ToolSource: toToolset for the schemas, the dotted
names, and createToolExecutor to run them.
function toToolSource<Ctx>(
router: object,
ctx: Ctx,
opts?: { format?: ToolFormat; approvals?: Approvals<Ctx> },
): ToolSource| Option | Type | Default | Description |
|---|---|---|---|
format | ToolFormat | 'anthropic' | Shape of the tool schemas. |
approvals | Approvals<Ctx> | — | Passed to createToolExecutor. |
mergeToolsets(...sources)
Merges sources into one flat list and one dispatcher. Each call goes to the source that owns the name.
function mergeToolsets(...sources: ToolSource[]): {
tools: unknown[]
execute: (name: string, args: unknown, call?: { signal?: AbortSignal }) => Promise<unknown>
}const local = toToolSource(appRouter, ctx, { format: 'anthropic' })
const github = await createMcpToolSource(githubClient, { prefix: 'gh.' })
const { tools, execute } = mergeToolsets(local, github)Throws a plain Error at merge time if two sources share a tool name; give one a
prefix. execute throws NOT_FOUND (Tool 'x' not found) for a name no source
owns.
ToolSource
interface ToolSource {
/** Tool schemas, formatted for the chosen provider. */
tools: unknown[]
/** The tool names this source answers to (used for routing). */
names: string[]
/** Run one of this source's tools by name. */
execute(name: string, args: unknown, call?: { signal?: AbortSignal }): Promise<unknown>
}Implement it yourself to merge in tools from anywhere else.
MCP server
dendra shapes tools/list and tools/call; it has no MCP transport. Wire these to
the official @modelcontextprotocol/sdk server (stdio or HTTP), as the
example app does.
mcpToolDefinitions(router)
The MCP tools/list payload for the router’s .ai() procedures. Names,
descriptions and schemas are the same as toToolset(router, { format: 'plain' }).
function mcpToolDefinitions(router: object): McpToolDef[]Each tool’s annotations start from defaults derived from the procedure, and
.ai({ annotations }) sets any of them over the defaults:
| Procedure | Default annotations |
|---|---|
.get() | { readOnlyHint: true } (this wins over confirm) |
.ai({ confirm: true }) | { readOnlyHint: false, destructiveHint: true } |
| anything else | none, which MCP treats as possibly destructive and open-world |
search: route.post('/search').input(query)
.ai({ annotations: { readOnlyHint: true } }) // a POST that only reads
.handler(…),
saveContact: route.input(contact)
.ai({ annotations: { idempotentHint: true, destructiveHint: false, openWorldHint: true } })
.handler(…),Clients use these hints to decide what to ask the user about. They’re hints, not
enforcement: to make a person approve a call, use confirm.
McpToolDef
interface McpToolDef {
name: string
description: string
inputSchema: Record<string, unknown>
/** Defaults for .get() and confirm tools, then .ai({ annotations }). Left out when empty. */
annotations?: ToolAnnotations
}createMcpBridge(router, ctx, opts?)
An in-process MCP server core: lists the router’s tools and calls them through
createToolExecutor with ctx. Create it per connection or request, with that
caller’s ctx.
function createMcpBridge<Ctx>(
router: object,
ctx: Ctx,
opts?: { approvals?: Approvals<Ctx> },
): {
listTools(): McpToolDef[]
callTool(name: string, args: unknown, call?: { signal?: AbortSignal }): Promise<McpCallResult>
}const bridge = createMcpBridge(appRouter, ctx, { approvals })
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: bridge.listTools() }))
server.setRequestHandler(CallToolRequestSchema, (req, extra) =>
bridge.callTool(req.params.name, req.params.arguments, { signal: extra.signal }))callTool throws only when call.signal aborts (the client cancelled, so the
SDK drops the response). It passes args ?? {} to the executor and returns the
result as one text item holding JSON.stringify(result). On failure it returns
isError: true with the error’s public message as the text: a DendraError’s own
message, or Internal error for anything else (logged with console.error). Only
the message is sent, not the code or details. A confirm tool returns its
PendingResult as the text, like any result.
McpCallResult
interface McpCallResult {
content: Array<{ type: 'text'; text: string }>
isError?: boolean
}MCP client
dendra can use another MCP server’s tools in two ways:
createMcpRoutermounts them in your router as procedures. Use it when a person must approve remote calls, or when your guards and audit hooks should cover them.createMcpToolSourcegives aToolSourceto merge withmergeToolsets.
Either way, pass a client that’s already connected (the official SDK’s Client
fits as is). Transports, connections and OAuth stay in your app.
External servers are untrusted
A remote server’s tool names, descriptions and results go to the model. A malicious or compromised server can put instructions in any of them (prompt injection, or “tool poisoning”), and a tool can do more than its description says. Treat them like any input from outside your app:
- Allow only the tools you need with
filter, by name, rather than everything the server lists. - Mount or prefix them (
gh.or{ gh: … }) so a remote tool can’t take a local tool’s name.mergeToolsetsrejects duplicate names. - Require approval for anything that writes, sends or spends:
createMcpRouter(client, { confirm })withcreateApprovals. - Audit them with an
.after()hook on the route you pass tocreateMcpRouter. - Descriptions are cut to 1,024 characters (
maxDescriptionLength), which limits how much text a server can put in front of the model on every turn.
createMcpRouter(client, opts?)
Lists an external MCP server’s tools and returns them as .ai() procedures, to
mount in your router. From then on they’re tools like your own: the tool
executor, toToolset, the MCP bridge and approvals all see them, .use()
guards and after-hooks on opts.route run for every call, and
inspectRouter lists them.
function createMcpRouter(client: McpClientLike, opts?: McpRouterOptions): Promise<RouterRecord>const gh = await createMcpRouter(githubClient, {
filter: (name) => ['search_code', 'create_issue'].includes(name),
confirm: (name) => name === 'create_issue', // a person approves each issue
route: authed.after(audit), // your guard and audit hook
})
const router = { ...appRouter, gh }
const approvals = createApprovals(router, { scope: (ctx) => ctx.tenantId })
const execute = createToolExecutor(router, ctx, { approvals })
// the model calls 'gh.create_issue' → { status: 'pending_approval', actionId }- A tool is mounted at its remote name, and a dotted name nests:
issues.createmounted atghis the toolgh.issues.create. It throws if a name is both a tool and a namespace of other tools (issuesandissues.create), or has an empty segment. - Its input schema is the server’s
inputSchema, published to the model and OpenAPI as is. dendra only checks that the input is an object (BAD_REQUESTotherwise); the server validates the rest. meta.mcp.toolis the server’s name for the tool, so a test can assert policy over remote tools:const remote = inspectRouter(router).filter((r) => r.meta.mcp) expect(remote.filter((r) => !r.confirm).map((r) => r.path)).toEqual(['gh.search_code'])- A call goes to
callToolwith the remote name. Results and errors work as increateMcpToolSource: JSON text is parsed, andisErrorisBAD_GATEWAYwith the server’s text. - It lists the tools once, when called. Call it again to pick up new tools.
McpRouterOptions
| Option | Type | Default | Description |
|---|---|---|---|
filter | (name: string) => boolean | — | Keep only tools whose remote name passes. |
confirm | boolean | ((name: string) => boolean) | false | Which tools need a person’s approval: true for all, or a predicate on the remote name. Calls go through the approvals passed to the tool executor. |
maxDescriptionLength | number | 1024 | Longer remote descriptions are cut, ending in …. |
route | a route builder | initDendra().route | The builder the tools are defined from, e.g. one with your .use() guards and .after() hooks. |
createMcpToolSource(client, opts?)
Turns an external MCP server into a ToolSource you can merge with your own tools.
Its tools don’t go through your router, so they get no approvals, guards or
after-hooks; use createMcpRouter for those.
function createMcpToolSource(client: McpClientLike, opts?: McpToolSourceOptions): Promise<ToolSource>const client = new Client({ name: 'my-app', version: '1.0.0' })
await client.connect(transport)
const github = await createMcpToolSource(client, {
prefix: 'gh.',
filter: (name) => !name.startsWith('delete_'),
})It calls listTools() once, when created; create a new source to pick up new tools.
Each tool’s description falls back to its name and is cut to
maxDescriptionLength. A missing inputSchema becomes
{ type: 'object', properties: {} }.
execute:
- Strips the prefix and calls
callTool({ name, arguments: args ?? {} }). - Joins the result’s text items. If the text parses as JSON, returns the parsed value, else the raw string.
- Throws
BAD_GATEWAY(502) when the server returnsisError, with the server’s text as the message (orMCP tool 'x' failedif there’s none). The server wrote that text for the model, so it passes through unmasked. - Throws
NOT_FOUNDfor a name it didn’t list. - With
call.signal, stops waiting when it aborts and rejects with its reason. The remote call isn’t cancelled: the MCP SDK takes a signal in a different argument in v1 and v2, soMcpClientLikedoesn’t pass one.
Errors thrown by the client itself (a dropped connection) are not wrapped. Remote tool names, descriptions and results reach the model as the server sent them; see External servers are untrusted.
McpToolSourceOptions
| Option | Type | Default | Description |
|---|---|---|---|
format | ToolFormat | 'anthropic' | Shape of the tool schemas. |
filter | (name: string) => boolean | — | Keep only tools whose remote name passes. |
prefix | string | — | Added to each tool name on the dendra side (e.g. 'gh.') to avoid clashes. Stripped before the call reaches the server. |
maxDescriptionLength | number | 1024 | Longer remote descriptions are cut, ending in …. |
McpClientLike
The two methods dendra needs from an MCP client. It’s structural, so any object of this shape works.
interface McpClientLike {
listTools(): Promise<{
tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }>
}>
callTool(params: { name: string; arguments?: Record<string, unknown> }): Promise<{
content?: Array<{ type: string; text?: string }>
isError?: boolean
[key: string]: unknown
}>
}Approvals
A procedure marked .ai({ confirm: true }) never runs on the model’s say-so. The
flow:
- Propose. The model calls the tool.
createToolExecutor(withapprovals) validates the input and runs the.use()guards with the model’s ctx, then stores aPendingActioninstead of running the handler. The model gets aPendingResult. After-hooks see the call withapproval: { id, status: 'pending' }. - Review. Your UI lists actions with
approvals.list(ctx). - Decide. A person calls
approve(id, ctx)orreject(id, ctx, reason?)with their own ctx. Approve validates the stored input again, re-runs the guards against the approver, and runs the handler with the approver’s ctx. After-hooks see the decision withstatus: 'approved'or'rejected'.
const approvals = createApprovals(appRouter, {
scope: (ctx) => ctx.tenantId,
proposer: (ctx) => ctx.user?.id,
})
// In the agent route, per request
const execute = createToolExecutor(appRouter, ctx, { approvals })
const res = await execute('posts.publish', { id: 42 })
if (isPendingResult(res)) {
// { status: 'pending_approval', actionId: '…', message: 'Waiting for a person to approve this action. It has not run yet.' }
}
// In the approver's request
const pending = await approvals.list(approverCtx)
const result = await approvals.approve(pending[0].id, approverCtx)
// or: await approvals.reject(id, approverCtx, 'Not this week')Over MCP, confirm tools carry destructiveHint: true, and createMcpBridge needs
the same approvals option to call them.
createApprovals(router, opts?)
Creates the pending-action store and the decision functions for a router’s confirm
tools. Create it once and pass the same object to every createToolExecutor,
toToolSource or createMcpBridge that should propose into it.
function createApprovals<Ctx>(router: object, opts?: ApprovalsOptions<Ctx>): Approvals<Ctx>ApprovalsOptions
| Option | Type | Default | Description |
|---|---|---|---|
store | ApprovalStore | createInMemoryApprovalStore() | Where pending actions live. Use a shared store with more than one process. |
scope | (ctx: Ctx) => string | undefined | — | Who may see and decide an action, e.g. the tenant id. Recorded at proposal; list, get, approve and reject only find actions whose scope equals the caller’s. Set it in multi-tenant apps. |
proposer | (ctx: Ctx) => string | undefined | — | Who the model was acting for, e.g. the user id. Recorded on the action as proposedBy, since the handler runs with the approver’s ctx. |
Approvals
| Method | Returns | Description |
|---|---|---|
list(ctx) | Promise<PendingAction[]> | Pending actions in the caller’s scope. |
get(id, ctx) | Promise<PendingAction | null> | One action, or null if it’s missing or in another scope. |
approve(id, ctx) | Promise<unknown> | Runs the call with the approver’s ctx and returns the handler’s result (a streaming handler’s chunks are collected as in createToolExecutor). |
reject(id, ctx, reason?) | Promise<void> | Drops the action. After-hooks record it as a FORBIDDEN failure with approval.reason; reject itself resolves. |
approve and reject both validate the stored input and run the guards with the
deciding ctx before anything else. The action is removed from the store (with
take) only after the guards pass, so:
- If validation or a guard fails, the error is thrown and the action stays pending.
- Once removed, it’s decided: if the handler then throws, the action is gone and the error is thrown to the approver.
Errors:
| Code | When |
|---|---|
NOT_FOUND | No action with that id in the caller’s scope (another tenant’s id answers the same), or its procedure no longer exists. |
CONFLICT | The action was already decided, e.g. two approvers raced. |
PendingAction
A stored tool call waiting for a decision.
| Field | Type | Description |
|---|---|---|
id | string | A random UUID; also the actionId the model got. |
tool | string | Dotted procedure path, e.g. 'posts.publish'. |
input | unknown | The model’s arguments as sent. Validated again on approval. |
scope | string | From scope(ctx) at proposal time, if set. |
proposedBy | string | From proposer(ctx) at proposal time, if set. |
createdAt | number | Date.now() at proposal time. |
PendingResult
What the model gets from a confirm tool instead of the result.
interface PendingResult {
status: 'pending_approval'
actionId: string
message: string // 'Waiting for a person to approve this action. It has not run yet.'
}isPendingResult(value)
True if a tool result is a PendingResult: an object with status: 'pending_approval' and a string actionId. Use it in your agent loop or UI to show
an approval prompt.
function isPendingResult(v: unknown): v is PendingResultcreateInMemoryApprovalStore()
A single-process ApprovalStore backed by a Map, and the default for
createApprovals. Actions are copied with structuredClone and lost on restart.
For development, tests and single-instance apps.
function createInMemoryApprovalStore(): ApprovalStoreApprovalStore
Implement this to keep pending actions in your database.
interface ApprovalStore {
add(action: PendingAction): Promise<void>
get(id: string): Promise<PendingAction | null>
/** Remove and return it, atomically, so an action is decided once. */
take(id: string): Promise<PendingAction | null>
list(scope?: string): Promise<PendingAction[]>
}take must be atomic (e.g. DELETE … RETURNING): when two approvers race, only
one may get the action back; the other gets null, which becomes CONFLICT.
list(scope) filters by scope; list(undefined) may return every action.
createApprovals passes the caller’s scope and then keeps only actions whose
scope equals it exactly, so a ctx whose scope(ctx) is undefined sees only
unscoped actions, whatever the store returns.