Skip to Content
API referenceAI tools & MCP

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[]
OptionTypeDefaultDescription
formatToolFormat'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.create becomes notes.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. runAgent and lazyToolset do 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.

OptionTypeDescription
approvalsApprovals<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:

CodeWhen
NOT_FOUNDNo 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_REQUESTInput validation failed (Input validation failed, issues in details).
FORBIDDENA confirm tool was called without approvals. It never runs.
anyA DendraError thrown by a guard or the handler passes through.
INTERNALAny 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): unknown

ToolFormat

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
OptionTypeDefaultDescription
formatToolFormat'anthropic'Shape of the tool schemas.
approvalsApprovals<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:

ProcedureDefault annotations
.get(){ readOnlyHint: true } (this wins over confirm)
.ai({ confirm: true }){ readOnlyHint: false, destructiveHint: true }
anything elsenone, 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:

  • createMcpRouter mounts 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.
  • createMcpToolSource gives a ToolSource to merge with mergeToolsets.

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. mergeToolsets rejects duplicate names.
  • Require approval for anything that writes, sends or spends: createMcpRouter(client, { confirm }) with createApprovals.
  • Audit them with an .after() hook on the route you pass to createMcpRouter.
  • 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.create mounted at gh is the tool gh.issues.create. It throws if a name is both a tool and a namespace of other tools (issues and issues.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_REQUEST otherwise); the server validates the rest.
  • meta.mcp.tool is 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 callTool with the remote name. Results and errors work as in createMcpToolSource: JSON text is parsed, and isError is BAD_GATEWAY with the server’s text.
  • It lists the tools once, when called. Call it again to pick up new tools.

McpRouterOptions

OptionTypeDefaultDescription
filter(name: string) => boolean—Keep only tools whose remote name passes.
confirmboolean | ((name: string) => boolean)falseWhich 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.
maxDescriptionLengthnumber1024Longer remote descriptions are cut, ending in ….
routea route builderinitDendra().routeThe 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 returns isError, with the server’s text as the message (or MCP tool 'x' failed if there’s none). The server wrote that text for the model, so it passes through unmasked.
  • Throws NOT_FOUND for 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, so McpClientLike doesn’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

OptionTypeDefaultDescription
formatToolFormat'anthropic'Shape of the tool schemas.
filter(name: string) => boolean—Keep only tools whose remote name passes.
prefixstring—Added to each tool name on the dendra side (e.g. 'gh.') to avoid clashes. Stripped before the call reaches the server.
maxDescriptionLengthnumber1024Longer 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:

  1. Propose. The model calls the tool. createToolExecutor (with approvals) validates the input and runs the .use() guards with the model’s ctx, then stores a PendingAction instead of running the handler. The model gets a PendingResult. After-hooks see the call with approval: { id, status: 'pending' }.
  2. Review. Your UI lists actions with approvals.list(ctx).
  3. Decide. A person calls approve(id, ctx) or reject(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 with status: '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

OptionTypeDefaultDescription
storeApprovalStorecreateInMemoryApprovalStore()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

MethodReturnsDescription
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:

CodeWhen
NOT_FOUNDNo action with that id in the caller’s scope (another tenant’s id answers the same), or its procedure no longer exists.
CONFLICTThe action was already decided, e.g. two approvers raced.

PendingAction

A stored tool call waiting for a decision.

FieldTypeDescription
idstringA random UUID; also the actionId the model got.
toolstringDotted procedure path, e.g. 'posts.publish'.
inputunknownThe model’s arguments as sent. Validated again on approval.
scopestringFrom scope(ctx) at proposal time, if set.
proposedBystringFrom proposer(ctx) at proposal time, if set.
createdAtnumberDate.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 PendingResult

createInMemoryApprovalStore()

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(): ApprovalStore

ApprovalStore

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.

Last updated on