SDK Reference — @ontrove/mcp
The @ontrove/mcp SDK is a thin standard library for authoring toolkits. It owns the MCP protocol, JSON-RPC, schema validation, auth context injection, secret access, and error envelopes — so you write only the handlers.
# Installed automatically by the scaffold; manual install:npm install @ontrove/mcp zodzod is a peer dependency — see The z export below.
For the complete, type-generated export list, see the full API reference (@ontrove/mcp).
defineMcpServer
Section titled “defineMcpServer”The single entry point. Your server.ts must export this as default.
import { defineMcpServer, z } from "@ontrove/mcp";
export default defineMcpServer({ tools: [/* tool definitions */], scopes: ["trove:search"], // optional — see below});Parameters
Section titled “Parameters”| Parameter | Type | Required | Description |
|---|---|---|---|
tools | ToolDefinition[] | Yes | Array of tool definitions. At least one required. |
scopes | string[] | No | The manifest-declared Trove scopes (e.g. ["trove:search"]). The SDK uses them only to auto-derive a conservative readOnlyHint default — a toolkit that declares trove:ingest writes, so its tools are not read-only by default. Mirror your manifest.json scopes here. |
auth | OAuth2ClientCredentials | No | Declarative auth — the SDK mints, caches, and attaches a Bearer token to egress automatically. See auth. |
auth (OAuth2 client credentials)
Section titled “auth (OAuth2 client credentials)”For APIs that use the OAuth2 client-credentials grant (e.g. eBay), declare auth and the SDK handles the entire token lifecycle — your handlers just call ctx.fetch/ctx.fetchJson and the Bearer is already attached.
export default defineMcpServer({ auth: { type: "oauth2_client_credentials", tokenUrl: "https://api.acme.com/identity/v1/oauth2/token", clientIdSecret: "ACME_CLIENT_ID", // a manifest `secrets` name clientSecretSecret: "ACME_CLIENT_SECRET", // a manifest `secrets` name scope: "https://api.acme.com/oauth/api_scope", // optional apiHost: "api.acme.com", // required: the Bearer is attached only to this host }, tools: [/* … */],});| Field | Required | Description |
|---|---|---|
type | Yes | Must be "oauth2_client_credentials". |
tokenUrl | Yes | Token endpoint (HTTP Basic client auth, grant_type=client_credentials). |
clientIdSecret / clientSecretSecret | Yes | Names of the vault secrets holding the client id / secret. Both must appear in manifest.json secrets. |
scope | No | Space-delimited scope string sent with the grant. |
apiHost | Yes | Required. The Bearer is attached ONLY to this host so the token never leaks to another egress target. Must also appear in manifest egress. |
The token is minted on first use, cached (refreshed ~60s early), and re-minted once automatically on a 401/403. List the token host (and apiHost) in manifest.json egress, and set the two secrets with trove secret set <toolkit> <NAME> --from-stdin. A handler-set Authorization header is never overwritten. For query-param API keys (not Bearer), use ctx.requireSecret + the key as a URL param instead.
Tool definition
Section titled “Tool definition”Each element of the tools array is a ToolDefinition object.
{ name: string; // required — snake_case, unique within the toolkit title?: string; // optional — friendly display name (MCP `title`) description: string; // required — what Claude reads to decide to call input: ZodSchema; // required — Zod schema for the arguments output?: ZodSchema; // optional — Zod schema for structured output annotations?: ToolAnnotations; // optional — behavioral hints (auto-derived) mutating?: boolean; // optional — marks the tool as state-changing alwaysOn?: boolean; // optional — surface even past the per-session cap handler: (args, ctx) => Promise<ToolResult | string>;}Only name, description, input, and handler are required. The rest are
best-practice extras the SDK fills with sensible defaults — see
Annotations & structured output.
A short, snake_case identifier. Must be unique within your toolkit (pattern [a-zA-Z0-9_-]+). The tool is surfaced to Claude as {toolkitId}__{name} (double underscore), e.g. acme-orders__lookup_order. Keep names concise and action-oriented.
An optional human-readable display name surfaced in tools/list as the MCP title. Clients use it in tool pickers; the model still reasons over name and description. Set it when the snake_case name isn’t a friendly label — title: "Look up order".
description
Section titled “description”The string Claude reads to decide whether to call this tool. Be specific about what the tool does, what it returns, and when to prefer it over alternatives. The description is part of your tool’s quality — treat it like prose, not a label.
description: "Look up the status, line items, and ship date of an Acme order by ID. " + "Use when the user asks about a specific order number.",A Zod schema describing the tool’s arguments. The SDK compiles this to the inputSchema JSON Schema returned in tools/list, and validates arguments before your handler runs — invalid args never reach your code.
input: z.object({ orderId: z.string().describe("The order ID, e.g. 'ORD-10423'."), includeLineItems: z.boolean().optional().default(false),}),Add .describe(...) to each field — these descriptions appear in the JSON Schema and help Claude form correct calls.
output
Section titled “output”An optional Zod schema describing the tool’s structured output. When present, the SDK compiles it to an outputSchema in tools/list, and the handler’s structured value is surfaced to the host as the spec structuredContent object (alongside the text mirror). Declaring output makes your tool’s machine-readable result validatable by default — see Annotations & structured output.
output: z.object({ status: z.string(), shipDate: z.string(), lineItems: z.array(z.object({ sku: z.string(), qty: z.number() })).optional(),}),annotations
Section titled “annotations”The four MCP behavioral hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) surfaced in tools/list. You rarely set these — the SDK auto-derives a conservative default (see below). An explicit author value always wins, field by field.
annotations: { readOnlyHint: true, openWorldHint: true }, // a read tool over a public APImutating
Section titled “mutating”boolean, default false. Marks the tool as state-changing, which forces client consent and flips the auto-derived readOnlyHint to false. Set it on any tool that writes to an external system or the user’s knowledge base.
alwaysOn
Section titled “alwaysOn”boolean. An ordering hint: surface this tool first in tools/list (it sorts ahead of the toolkit’s other tools). Reserve it for a toolkit’s most important tool.
handler
Section titled “handler”An async function that receives the validated arguments and a ToolContext object, and returns a ToolResult (or a bare string shorthand for { text }). The args parameter is fully typed — inferred from the input Zod schema.
async handler({ orderId, includeLineItems }, ctx) { const token = await ctx.secret("ORDERS_API_TOKEN"); const res = await ctx.fetch(`https://orders.acme.com/v1/${orderId}`, { headers: { authorization: `Bearer ${token}` }, }); // ... return { text: `Order ${orderId}: shipped.` };}ToolContext (ctx)
Section titled “ToolContext (ctx)”The second argument to every handler. A deliberately small capability object — no ambient authority, no raw bindings. Everything ctx can do is something the manifest declared and the calling user authorized.
ctx.userId
Section titled “ctx.userId”ctx.userId: stringThe authenticated Clerk user ID of the person calling the tool. An opaque string — an identity, not a credential. Use it if you need to scope behavior to the calling user, e.g. in a log message or a request header.
ctx.secret(name)
Section titled “ctx.secret(name)”ctx.secret(name: string): Promise<string>Fetches one declared secret from the encrypted vault, decrypted only for this invocation. The name must appear in your manifest.json secrets array; any other name throws.
const token = await ctx.secret("ORDERS_API_TOKEN");const apiKey = await ctx.secret("METRICS_READ_KEY");The decrypted value exists only for the lifetime of the isolate. It is never returned to the caller, never logged, and never written to any store.
ctx.requireSecret(name)
Section titled “ctx.requireSecret(name)”ctx.requireSecret(name: string): Promise<string>Like ctx.secret, but throws a clear, model-visible ToolError (non-retryable) when the secret is missing or empty: "<name> is not set. Run \trove secret set Use this for required credentials — it removes the try/catch boilerplate.ctx.secret` remains the raw escape hatch when you want to handle absence yourself.
const token = await ctx.requireSecret("ORDERS_API_TOKEN");ctx.fetch(url, init?)
Section titled “ctx.fetch(url, init?)”ctx.fetch(url: string | URL, init?: RequestInit): Promise<Response>The only egress path out of your toolkit. Behaves like the standard fetch API, but routes through Trove’s egress layer, which enforces your allowlist. Any request to a host not listed in manifest.json egress is blocked with a network error.
const res = await ctx.fetch("https://orders.acme.com/v1/ORD-10423", { headers: { authorization: `Bearer ${token}` },});A default browser User-Agent is added when you don’t set one (many public APIs reject the default runtime UA). An explicit user-agent header always wins. When the toolkit declares auth, a Bearer token is attached automatically.
ctx.fetchJson(url, opts?)
Section titled “ctx.fetchJson(url, opts?)”ctx.fetchJson<T>(url, opts: { schema: ZodType<T>; init?; errorMap? }): Promise<T>ctx.fetchJson(url, opts?: { init?; errorMap? }): Promise<unknown>The batteries-included way to call a JSON API — it replaces the hand-rolled “fetch → check status → parse → throw” helper most toolkits used to copy. It routes through ctx.fetch (so the default UA and auth apply), then:
- maps a non-2xx status to a
ToolError— 4xx (except 429) → non-retryable; 429 / 5xx / network error → retryable; - guards malformed JSON (retryable);
- when you pass a Zod
schema, validates the body and returns it fully typed (omitschemato get parsedunknown).
const Book = z.object({ title: z.string().default("Untitled"), isbn: z.array(z.string()).default([]) });const { docs } = await ctx.fetchJson(url, { schema: z.object({ docs: z.array(Book).default([]) }),}); // docs is typed — no casts, no guardsOverride the status mapping with errorMap(res, body) (it receives the already-read body text, so you can surface the upstream’s own error message); return undefined to fall back to the default.
ctx.trove
Section titled “ctx.trove”ctx.trove: TroveClient | undefinedA scoped client over the calling user’s own Trove knowledge base. Only available if manifest.json scopes includes "trove:search" (for reading) or "trove:ingest" (for writing). If neither scope was declared, ctx.trove is undefined — guard with ctx.trove?.… or check for its presence.
// manifest.json: "scopes": ["trove:search"]
// search(query, opts?) — query is positional; opts is optional.const results = await ctx.trove?.search("order fulfillment", { limit: 5 });Available methods on ctx.trove when scopes are granted:
| Method | Signature | Scope required | Description |
|---|---|---|---|
search | search(query: string, opts?: { limit?, source?, author? }): Promise<TroveSearchResult[]> | trove:search | Semantic search over the user’s knowledge base |
getDocument | getDocument(id: string): Promise<TroveDocument> | trove:search | Fetch a full document by ID |
ingest | ingest(docs: TroveIngestDoc[]): Promise<{ ingested: number }> | trove:ingest | Write documents into the knowledge base |
The trove:ingest scope plus ingest is the basis for toolkits that write back to the user’s Trove. See the knowledge-base tool example for a toolkit that both reads and writes the user’s KB.
TroveIngestDoc
Section titled “TroveIngestDoc”| Field | Type | Description |
|---|---|---|
title | string | Required. The document title. |
text | string? | The body to index. Optional when you supply a fileUrl/audioUrl (the captured file becomes the body); required otherwise. |
url | string? | Canonical URL of the original. |
author | string? | Free-text byline. |
fileUrl | string? | A file to capture by URL (PDF, audio, …). Trove fetches and stores the artifact, then processes it (PDF → text, audio → transcript). Public-internet URL only — egress is SSRF-guarded. |
audioUrl | string? | Alias for an audio fileUrl (implies audio/mpeg). |
mimeType | string? | MIME type for fileUrl, e.g. application/pdf. |
captureOnly | boolean? | Store the artifact plus a searchable metadata record, and skip the AI processing. Capture now, enrich later. |
feed | TroveIngestFeed? | The sub-group this document belongs to within your toolkit. See below. |
You never name a source. A saved document is attributed to your toolkit automatically, from the toolkit identity the runtime verified for this invocation — a caller cannot forge that attribution, and your toolkit cannot write into another toolkit’s documents.
Feeds — grouping a toolkit’s documents
Section titled “Feeds — grouping a toolkit’s documents”A toolkit’s documents can sit in one flat list, or cluster into named feeds. A YouTube toolkit groups by channel, a podcast toolkit by show, a filings toolkit by company, an economic-data toolkit by series. You choose the grouping entity — it is the thing a user would say “show me everything from ___” about.
feed is optional. Omit it and your documents form a flat list under your toolkit. Declare it and they group:
await ctx.trove?.ingest([ { title: video.title, text: transcript, url: video.url, feed: { key: "UCBJycsmduvYEL83R_U4JriQ", // stable upstream id of the entity name: "MKBHD", // what the user sees label: "Channel", // optional: what kind of thing it is }, },]);| Field | Type | Description |
|---|---|---|
key | string | Required. The entity’s stable upstream id — not its display name. It survives a rename, and it is the boundary Trove dedups within. |
name | string | Required. The human-readable feed name shown to the user. |
label | string? | The word for what kind of thing this feed is — "Channel", "Show", "Company", "Series" — so a client can say “grouped by Company” rather than the generic “feed”. |
Pick a grouping that is single-valued and stably keyed. A joined multi-author string is neither, so it makes a poor feed even though it makes a fine author.
ctx.log(...args)
Section titled “ctx.log(...args)”ctx.log(...args: unknown[]): voidStructured log entry. Calls are captured per invocation by the deployed runtime; log values are redacted against all known secret values for the calling user before they are written. A log-viewing command (trove mcp logs <toolkit>) is planned but does not yet stream these logs — see Deploying → Logs.
ctx.log("fetching order", { orderId, userId: ctx.userId });ToolResult
Section titled “ToolResult”The object returned by your handler. At minimum provide text; structured is optional additional data.
return { text: "Order ORD-10423: shipped, expected delivery 2026-06-15.", structured: order, // any JSON-serializable value};| Field | Type | Description |
|---|---|---|
text | string | The model-visible response body. Always provide this. |
structured | unknown (JSON) | Optional structured data attached to the result. Some clients display this; Claude can read it. |
You may also return a plain string as a shorthand for { text: string }.
When the tool declares an output schema and the handler returns a structured value, that value is emitted to the host as the spec structuredContent object alongside the text mirror — so clients get validatable data for free. Without an output schema, structured is still attached to the result but is not surfaced as structuredContent.
Annotations & structured output
Section titled “Annotations & structured output”The SDK makes the MCP best practices the default, so every toolkit is conformant by construction.
Auto-derived annotations
Section titled “Auto-derived annotations”The MCP spec’s conservative absence default treats a tool as readOnlyHint: false, destructiveHint: true, openWorldHint: true — which would add needless confirmation friction to a plain read tool. The SDK instead derives a safer default, applied field by field, an explicit author value always wins:
readOnlyHintdefaults totrueUNLESS the tool declares write intent — eithermutating: trueon the tool, or the toolkit’sscopesincludetrove:ingest. A write-intent tool defaults toreadOnlyHint: false.- When the resolved tool is read-only,
destructiveHintandopenWorldHintdefault tofalse(a reader neither destroys nor reaches the open world). - The SDK never invents a destructive or open-world claim for a write tool, and never overrides a hint you set explicitly.
So a non-mutating tool needs no annotations at all. Set them only to override — e.g. a read tool that calls a public third-party API should add openWorldHint: true:
{ name: "search_quakes", title: "Search earthquakes", description: "Search recent earthquakes from the public USGS feed.", // Read-only is auto-derived; declare open-world for the public API. annotations: { readOnlyHint: true, openWorldHint: true }, input: z.object({ minMagnitude: z.number().describe("Minimum magnitude.") }), output: z.object({ count: z.number(), quakes: z.array(z.object({ place: z.string(), magnitude: z.number(), })) }), async handler({ minMagnitude }, ctx) { /* … */ },}Structured output
Section titled “Structured output”Add an output Zod schema and return a structured value from the handler; the SDK compiles outputSchema into tools/list and emits structuredContent on the result. This is opt-in per tool but recommended for any tool whose result is naturally structured data.
ToolError
Section titled “ToolError”Throw ToolError to return a clean, intentional error to the model. Uncaught exceptions are also caught by the SDK and returned as a generic error — but ToolError lets you control the message and the retryable hint.
import { ToolError } from "@ontrove/mcp";
// Non-retryable: the order doesn't exist; retrying won't help.throw new ToolError(`Order ${orderId} not found`, { retryable: false });
// Retryable: the upstream API is temporarily unavailable.throw new ToolError("Orders API is temporarily unavailable", { retryable: true });Constructor
Section titled “Constructor”new ToolError(message: string, options?: { retryable?: boolean; data?: unknown })| Parameter | Type | Default | Description |
|---|---|---|---|
message | string | required | Human-readable error message returned to the model. |
options.retryable | boolean | false | Whether the model should consider retrying the call. |
options.data | unknown | undefined | Optional structured data carried to your logs (not to the model). |
A ToolError is normalized to { ok: false, code: "TOOL_ERROR", error, retryable }. Any uncaught throw is also caught by the SDK and returned as a generic tool failed (code: "TOOL_ERROR", retryable: false) — never a stack trace to the model. Invalid arguments are rejected before your handler runs with code: "INVALID_PARAMS".
The z export
Section titled “The z export”zod is a peer dependency of @ontrove/mcp (install it alongside: npm install @ontrove/mcp zod). The SDK accepts your Zod schemas across its API, so author and SDK must share a single zod instance — two copies would break instanceof checks. For convenience the SDK re-exports Zod as z, so in your code you can import it straight from @ontrove/mcp and never reference zod directly:
import { defineMcpServer, z, ToolError } from "@ontrove/mcp";If you prefer to import Zod directly (import { z } from "zod"), that works too — it resolves to the same shared instance.
TypeScript types
Section titled “TypeScript types”Key types exported from @ontrove/mcp:
import type { ToolDefinition, ToolContext, ToolResult, ToolAnnotations, McpServerConfig, McpServerDefinition, TroveClient, TroveSearchResult, TroveDocument, TroveIngestDoc,} from "@ontrove/mcp";The runtime helpers toFetchHandler(server) and dispatch(server, call) are also exported for local tests (wrap your definition and drive it without a Workers isolate); inject a mock fetch via defineMcpServer(config, { fetchImpl }).
defineMcpServer is fully typed: the args parameter in your handler is inferred from the input Zod schema, so you get autocomplete and type errors without any manual typing.
Complete example
Section titled “Complete example”import { defineMcpServer, z, ToolError } from "@ontrove/mcp";
export default defineMcpServer({ tools: [ { name: "lookup_order", title: "Look up order", description: "Look up the status, line items, and ship date of an Acme order by ID. " + "Use when the user asks about a specific order number.", // Read-only is auto-derived; this toolkit only reads a public SaaS API. input: z.object({ orderId: z.string().describe("The order ID, e.g. 'ORD-10423'."), includeLineItems: z .boolean() .optional() .default(false) .describe("Whether to include the full line-item breakdown."), }), // An `output` schema → `outputSchema` + `structuredContent` (best practice). output: z.object({ status: z.string(), shipDate: z.string(), lineItems: z.array(z.object({ sku: z.string(), qty: z.number() })).optional(), }), async handler({ orderId, includeLineItems }, ctx) { const token = await ctx.secret("ORDERS_API_TOKEN");
ctx.log("lookup_order called", { orderId, includeLineItems });
const res = await ctx.fetch( `https://orders.acme.com/v1/${orderId}`, { headers: { authorization: `Bearer ${token}` } } );
if (res.status === 404) { throw new ToolError(`Order ${orderId} not found`, { retryable: false }); } if (!res.ok) { throw new ToolError("Orders API error, please try again", { retryable: true }); }
const order = (await res.json()) as { status: string; shipDate: string; lineItems: Array<{ sku: string; qty: number }>; };
const summary = `Order ${orderId}: ${order.status}, ships ${order.shipDate}.`; const detail = includeLineItems ? order.lineItems.map((li) => ` ${li.sku} ×${li.qty}`).join("\n") : "";
return { text: detail ? `${summary}\n\nLine items:\n${detail}` : summary, structured: order, }; }, }, ],});