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 mcp init acme-orders
cd acme-orders

This creates a folder with two files:

acme-orders/
manifest.json ← what the toolkit is (id, name, secrets, egress)
server.ts ← your tool handlers

Open server.ts. Replace the scaffold with:

server.ts
import { defineMcpServer, z, ToolError } from "@ontrove/mcp";
export default defineMcpServer({
tools: [
{
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.
const token = await ctx.secret("ORDERS_API_TOKEN");
// ctx.fetch is the only egress path. The host must be in manifest.json "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:

  • title + output + auto-derived annotations: a friendly picker name, a validatable structured result (outputSchemastructuredContent), and a readOnlyHint the SDK derives for you — so a read tool needs no annotations at all.
  • ctx.secret("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 listed in manifest.json 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.

Open manifest.json and confirm it matches:

manifest.json
{
"id": "acme-orders",
"name": "Acme Orders",
"description": "Query Acme order status.",
"version": "1.0.0",
"sdk": "@ontrove/mcp@^1",
"secrets": ["ORDERS_API_TOKEN"],
"egress": ["orders.acme.com"],
"scopes": [],
"visibility": "private"
}

The secrets and egress arrays are policy declarations: the platform provisions exactly what you list and blocks everything else. See manifest.json Reference for every field.

Terminal window
trove mcp deploy

You should see output like:

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

trove mcp deploy reads manifest.json from the current directory, bundles server.ts, and registers/versions the toolkit via the deployServer GraphQL mutation. (trove deploy is a shorthand alias for trove mcp 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, any tool call that requests it returns an 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.