Skip to content

Quickstart — Your First Toolkit

This guide walks you through building and deploying your own toolkit from scratch. (Every toolkit runs as a full MCP server on Trove’s cloud, so “server” below means the code you write — not infrastructure you run.) By the end, a new tool will appear in your Trove connection inside Claude — reachable from desktop, web, and mobile — with no server to operate.

We’ll build an Acme Orders toolkit: a single tool that looks up an order by ID against a fictional public SaaS REST API. The same pattern applies to any REST API, database proxy, or SaaS with a bearer token.

  • Trove CLI installed (npm install -g @ontrove/cli or bun add -g @ontrove/cli)
  • Logged in: trove login
  • Node 20+ or Bun 1.1+
Terminal window
trove toolkit init acme-orders
cd acme-orders

This creates a folder with two files:

acme-orders/
extension.ts ← the one defineToolkit call: what the toolkit is, and its tools
manifest.json ← generated from it; you never edit it by hand

Both are written from the same declaration. extension.ts is what you edit; manifest.json is the copy trove toolkit deploy reads off disk, and it carries "generated": true to say so. See the Toolkit Manifest Reference for every field and why the file still exists.

Open extension.ts. Replace the scaffolded tool with:

extension.ts
import { defineToolkit, tool, z, ToolError } from "@ontrove/extend/toolkit";
export default defineToolkit({
// Identity: who the toolkit is. All five are required.
id: "acme-orders",
name: "Acme Orders",
description: "Query Acme order status.",
icon: "📦",
version: "1.0.0",
// Policy: the credential names it reads and the hosts it may reach.
secrets: ["ORDERS_API_TOKEN"],
egress: ["orders.acme.com"],
tools: [
// `tool()` is what keeps `args` typed from this tool's own `input` schema.
tool({
name: "lookup_order",
// `title` is a friendly display name for client tool pickers.
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.",
// This tool only reads, so the SDK auto-derives `readOnlyHint: true` — no
// `annotations` needed. (You'd set them only to override, e.g.
// `openWorldHint: true` for a public third-party API.)
input: z.object({
orderId: z.string().describe("The order ID, e.g. 'ORD-10423'."),
}),
// An `output` schema compiles to `outputSchema` in `tools/list`, and the
// handler's `structured` value is emitted as the spec `structuredContent`.
output: z.object({
status: z.string(),
shipDate: z.string(),
lineItems: z.array(z.object({ sku: z.string(), qty: z.number() })).optional(),
}),
async handler({ orderId }, ctx) {
// Fetch the API token from the encrypted vault — never bundled in the
// script. `requireSecret` raises when it isn't set; `secret` resolves
// `undefined` for a credential you can work without.
const token = await ctx.requireSecret("ORDERS_API_TOKEN");
// ctx.fetch is the only egress path. The host must be in `egress`.
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 }>;
};
return {
// `structured` matches the `output` schema → surfaced as structuredContent.
text: `Order ${orderId}: ${order.status}, ships ${order.shipDate}.`,
structured: order,
};
},
}),
],
});

What this teaches — the best-practice defaults the SDK ships:

  • One declaration. id, name, description, icon and version are required and live next to the tools. defineToolkit validates them when the module is imported, so a missing field is an error at your desk, not on a tool call.
  • tool() around each definition. It returns its argument untouched and costs nothing at run time, but it captures the input schema’s type, so args is typed. Without it, args is unknown.
  • secrets and egress are policy. The platform provisions exactly what you list and blocks everything else. Mirror any change into manifest.json, which is the copy deploy reads.
  • ctx.requireSecret("ORDERS_API_TOKEN") fetches the token from the vault at call time. The value is never in your bundle or any database column.
  • ctx.fetch(...) is the only way to make outbound HTTP. Any host not in egress is blocked.
  • throw new ToolError(...) returns a clean error to the model. Uncaught exceptions are also caught and sanitized — no stack traces reach Claude.

The scaffold already wrote manifest.json from the same declaration. Because you changed secrets and egress in step 2, mirror them into the file — it is what deploy reads:

manifest.json
{
"id": "acme-orders",
"name": "Acme Orders",
"description": "Query Acme order status.",
"icon": "📦",
"version": "1.0.0",
"secrets": ["ORDERS_API_TOKEN"],
"egress": ["orders.acme.com"],
"scopes": [],
"visibility": "private",
"generated": true
}

Nothing regenerates the file today, which is the one place this workflow still asks you to repeat yourself. See Why it is generated.

Terminal window
trove toolkit deploy

You should see output like:

Bundling extension.ts…
✓ deployed acme-orders (version 1.0.0, building)
acme-orders__lookup_order

trove toolkit deploy reads manifest.json from the current directory, bundles extension.ts, and registers/versions the toolkit via the deployServer GraphQL mutation. (trove deploy is a shorthand alias for trove toolkit deploy.)

Secrets are scoped to a deployed toolkit, so set them by toolkit slug after the first deploy:

Terminal window
trove secret set acme-orders ORDERS_API_TOKEN --value "sk_live_…"
# Or, to keep the value out of shell history:
trove secret set acme-orders ORDERS_API_TOKEN --from-stdin # reads the value from stdin
trove secret set acme-orders ORDERS_API_TOKEN --from-file ./token.txt

The value is sealed into the encrypted vault (setServerSecret) and never lands in a database column or a log line. Until a declared secret has a value, ctx.requireSecret raises and the tool call returns that error. Rotating a secret is the same command again — no redeploy required.

The next time Claude lists tools on your Trove connection, acme-orders__lookup_order appears alongside trove_search. Ask Claude:

What’s the status of order ORD-10423?

Claude calls acme-orders__lookup_order({ orderId: "ORD-10423" }). The gateway dispatches it to your isolate, the handler fetches the API using the vaulted token, and the result comes back.

Total infrastructure you operate: none.

  • SDK Referencectx capabilities, ToolError, annotations, structured output
  • Toolkit Manifest Reference — every field you declare, and why the file is generated
  • Secrets and Auth — secret rotation, egress allowlisting, OAuth connections
  • Deploying — versioning, rollback, lifecycle
  • Examples — complete, copy-pasteable toolkits (USGS, NASA, knowledge-base)