Skip to content

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.

Terminal window
# Installed automatically by the scaffold; manual install:
npm install @ontrove/mcp zod

zod is a peer dependency — see The z export below.

For the complete, type-generated export list, see the full API reference (@ontrove/mcp).

The single entry point. Your server.ts must export this as default.

server.ts
import { defineMcpServer, z } from "@ontrove/mcp";
export default defineMcpServer({
tools: [/* tool definitions */],
scopes: ["trove:search"], // optional — see below
});
ParameterTypeRequiredDescription
toolsToolDefinition[]YesArray of tool definitions. At least one required.
scopesstring[]NoThe 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.
authOAuth2ClientCredentialsNoDeclarative auth — the SDK mints, caches, and attaches a Bearer token to egress automatically. See auth.

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: [/* … */],
});
FieldRequiredDescription
typeYesMust be "oauth2_client_credentials".
tokenUrlYesToken endpoint (HTTP Basic client auth, grant_type=client_credentials).
clientIdSecret / clientSecretSecretYesNames of the vault secrets holding the client id / secret. Both must appear in manifest.json secrets.
scopeNoSpace-delimited scope string sent with the grant.
apiHostYesRequired. 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.


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".

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.

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(),
}),

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 API

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.

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.

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.` };
}

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

The 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: 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: 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 —from-stdin`.”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: 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<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 ToolError4xx (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 (omit schema to get parsed unknown).
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 guards

Override 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: TroveClient | undefined

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

MethodSignatureScope requiredDescription
searchsearch(query: string, opts?: { limit?, source?, author? }): Promise<TroveSearchResult[]>trove:searchSemantic search over the user’s knowledge base
getDocumentgetDocument(id: string): Promise<TroveDocument>trove:searchFetch a full document by ID
ingestingest(docs: TroveIngestDoc[]): Promise<{ ingested: number }>trove:ingestWrite 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.

FieldTypeDescription
titlestringRequired. The document title.
textstring?The body to index. Optional when you supply a fileUrl/audioUrl (the captured file becomes the body); required otherwise.
urlstring?Canonical URL of the original.
authorstring?Free-text byline.
fileUrlstring?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.
audioUrlstring?Alias for an audio fileUrl (implies audio/mpeg).
mimeTypestring?MIME type for fileUrl, e.g. application/pdf.
captureOnlyboolean?Store the artifact plus a searchable metadata record, and skip the AI processing. Capture now, enrich later.
feedTroveIngestFeed?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
},
},
]);
FieldTypeDescription
keystringRequired. The entity’s stable upstream id — not its display name. It survives a rename, and it is the boundary Trove dedups within.
namestringRequired. The human-readable feed name shown to the user.
labelstring?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: unknown[]): void

Structured 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 });

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
};
FieldTypeDescription
textstringThe model-visible response body. Always provide this.
structuredunknown (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.


The SDK makes the MCP best practices the default, so every toolkit is conformant by construction.

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:

  • readOnlyHint defaults to true UNLESS the tool declares write intent — either mutating: true on the tool, or the toolkit’s scopes include trove:ingest. A write-intent tool defaults to readOnlyHint: false.
  • When the resolved tool is read-only, destructiveHint and openWorldHint default to false (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) { /* … */ },
}

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.


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 });
new ToolError(message: string, options?: { retryable?: boolean; data?: unknown })
ParameterTypeDefaultDescription
messagestringrequiredHuman-readable error message returned to the model.
options.retryablebooleanfalseWhether the model should consider retrying the call.
options.dataunknownundefinedOptional 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".


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.


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.


server.ts
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,
};
},
},
],
});