Writing MCP tools
Every tool in this repo is a thin wrapper around a Vantage REST endpoint, registered through registerTool and tested with testTool. The recipe below is what a new tool should look like. Read it before adding files under src/tools/.
Where the file goes
New tools are nested under a resource directory. Pick (or create) a directory named after the resource family — e.g. src/tools/budgets/, src/tools/recommendation-views/. Inside that directory:
src/tools/<resource>/
index.ts # one `import "./<verb>-<resource>"` line per tool
<verb>-<resource>.ts # tool file (list, get, create, update, delete, …)
schemas.ts # shared zod objects when multiple tools in the folder reuse them
test/tools/<resource>/
<verb>-<resource>.test.ts # unit tests — mirrors the src layout under test/
Create src/tools/<resource>/index.ts with one import "./<verb>-<resource>" line per tool, then run npm run generate-tools-index. That regenerates src/tools/index.ts to include the new directory — do not edit src/tools/index.ts by hand.
Do not add tool files at the top level of src/tools/. Every tool belongs under its resource directory.
Shared schemas (schemas.ts)
Nesting tools under one resource folder exists so siblings can share zod without copy-paste. When two or more tools in src/tools/<resource>/ use the same argument shapes (or the same shape with create defaults vs update optionals), extract them into schemas.ts in that folder — do not duplicate constants or z.object(...) blocks across create-* and update-* files.
Reference: src/tools/budgets/schemas.ts (budgetPeriod for create-budget and update-budget). Cost reports: src/tools/cost-reports/schemas.ts (chartTypes, chartSettings, businessMetricTokenForCreate / businessMetricTokenForUpdate, costReportSettingsForCreate / costReportSettingsForUpdate, dateBins).
Rules:
- Import shared exports from
./schemasin each tool file; keep tool-specific fields (tokens, titles, one-off describes) in the tool file. - If create and update differ only by
.default(...)vs.optional(), export two named schemas (e.g.costReportSettingsForCreateandcostReportSettingsForUpdate) rather than maintaining two nearly identical inline copies. - Add or extend
schemas.tsin the same PR as a new create/update pair when you introduce the second file that would duplicate an existing definition. schemas.tsis not imported fromindex.ts— only tool files import it. It is not registered as a tool.
Tool anatomy
Every tool exports a registerTool call as the default export. The minimum surface:
import z from "zod/v4";
import MCPUserError from "../structure/MCPUserError";
import registerTool from "../structure/registerTool";
const description = `
One or two sentences. What this returns and when to reach for it.
`.trim();
export default registerTool({
name: "list-widgets", // kebab-case, matches filename
title: "List Widgets", // Title Case, shown in UIs
description,
annotations: {
readOnly: true,
destructive: false,
openWorld: false,
},
args: {
// zod schema — see "Args / zod" below
},
async execute(args, ctx) {
const response = await ctx.callVantageApi("/v2/widgets", args, "GET");
if (!response.ok) {
throw new MCPUserError({ errors: response.errors });
}
return response.data;
},
});
Reference implementations to copy from:
- list:
src/tools/budgets/list-budgets.ts - get:
src/tools/budgets/get-budget.ts - create:
src/tools/budgets/create-budget.ts - update:
src/tools/budgets/update-budget.ts - delete:
src/tools/folders/delete-folder.ts(see "Delete tools" below)
Discovering the API surface
Before writing args or guessing at response fields, read the generated TypeScript client at @vantage-sh/vantage-client (node_modules/@vantage-sh/vantage-client/dist/index.d.ts). It is the source of truth for:
- the set of endpoint paths
ctx.callVantageApiaccepts as the first argument, - the request body / query param shape for each
(path, method)pair, and - the response body shape you get back on
response.ok.
Use it to drive the zod schema rather than guessing field names from API docs or other tools. The two helpers worth knowing:
import type { RequestBodyForPathAndMethod, ResponseBodyForPathAndMethod } from "@vantage-sh/vantage-client";
type CreateWidgetRequest = RequestBodyForPathAndMethod<"/v2/widgets", "POST">;
type CreateWidgetResponse = ResponseBodyForPathAndMethod<"/v2/widgets", "POST">;
Cast args to the request type at the callVantageApi call site if the zod-inferred type doesn't line up exactly (see create-recommendation-view.ts for the pattern). If a field exists in the client types but you don't expose it in your zod schema, that's a deliberate choice — leave a one-line note in code if the omission isn't obvious.
Annotations
The three hints are sent to clients as readOnlyHint, destructiveHint, and openWorldHint. Set them per the MCP spec — not "whatever similar tools happen to have." Some existing tools drift from this; new tools should be correct.
| Verb | readOnly |
destructive |
openWorld |
|---|---|---|---|
list-* |
true |
false |
false |
get-* |
true |
false |
false |
create-* |
false |
false |
false |
update-* |
false |
true |
false |
delete-* |
false |
true |
false |
Why create-* is not destructive: per the spec, destructiveHint means "may perform destructive updates." Creating a new entity is additive — calling it again creates another row, it doesn't overwrite anything. Reserve destructive: true for tools that modify or remove existing state.
Why openWorld: false: Vantage tools only interact with the Vantage API — a bounded, known surface. openWorld: true is for tools that hit the broader internet (web search, arbitrary HTTP fetches). None of our tools should set this to true.
If you find yourself wanting to flag a non-obvious case (e.g. a cancel-* action, a bulk operation), pick hints by asking: "does this modify or remove existing state?" → destructive. "Does it touch unknown external systems?" → openWorld.
Description style
Goal: minimum prose, strong schema. The description should be just enough for an LLM to know when to call the tool. Everything about what to pass belongs in .describe() strings on the zod fields, not in the description.
Keep:
- One or two sentences on what the tool returns / what it's for.
- Disambiguation against neighbouring tools when names are confusable (see
create-cost-alert.tscalling out Report Notifications). - Non-obvious context the model can't infer — e.g. that a token can be turned into a console URL (
https://console.vantage.sh/go/<token>), VQL syntax rules forquery-costs, pagination conventions.
Cut:
- Restating each argument — that's what
.describe()is for. - Describing the response shape — the model sees the result.
- Long onboarding paragraphs ("This tool is useful when…"). If the trigger condition needs explaining, one sentence is the cap.
When the user opts in, evals can measure description quality — see .agents/skills/writing-evals/SKILL.md. A description is "good" when an eval can drive correct tool selection and correct argument shape from minimal prose. If an eval is failing, fix the description or the zod schema before reaching for more prose — and reach for the schema first.
Args / zod
Zod is doing most of the work. Make each field carry its weight:
args: {
page: z.number().int().min(1).optional().default(1).describe("Page number, defaults to 1"),
workspace_token: vantageToken("workspace"),
title: nonempty().describe("Budget title"),
start_date: dateValidator("Start date, YYYY-MM-DD").optional(),
}
Patterns to use:
vantageToken("…")from../../utils/zodfor any Vantage*_tokenarg. Validates the prefix and builds the describe string (Workspace token (\wrkspc_*`).). Pass{ description }for contextual prose. Prefixes live in../../utils/zod/token-kinds.tsand are limited to token kinds accepted by public routes and request fields in@vantage-sh/vantage-client` — do not add internal-only tokenizable models.nonempty()from../../utils/zodinstead ofz.string().min(1)for free-text fields (trims before checking length).dateValidator("…")from../../utils/dateValidatorfor anyYYYY-MM-DDfield.DEFAULT_LIMITfrom../structure/constantswhen paginating.paginationData(response.data)from../../utils/paginationDatato compute{ hasNextPage, nextPage }for list tools.pathEncodefrom@vantage-sh/vantage-clientfor any token interpolated into a URL path.- Request body types:
RequestBodyForPathAndMethod<"/v2/…", "POST">from@vantage-sh/vantage-clientwhen you need to assert the body shape (seecreate-recommendation-view.ts). - Shared sub-schemas go in
src/tools/<resource>/schemas.tswhen used by more than one tool in that folder (see "Shared schemas"). Import withfrom "./schemas".
In .describe() strings: name the thing, give the format, and point at the tool that can discover valid values ("Use list-cost-providers to discover valid provider names"). Don't restate types — z.number() already says it's a number. For tokens, prefer vantageToken over hand-written describes.
Execute
execute(args, ctx) is async and uses ctx.callVantageApi(path, params, method). Always check response.ok and throw MCPUserError on failure:
if (!response.ok) {
throw new MCPUserError({ errors: response.errors });
}
return response.data;
Don't catch errors yourself unless you're adding context — registerTool already turns MCPUserError into a structured tool error response.
List tools — pagination shape
return {
widgets: response.data.widgets,
pagination: paginationData(response.data),
};
Delete tools — return shape
Return { token: args.<resource>_token }. This confirms what was deleted without leaking extra fields. src/shared.ts handles HTTP 204 by returning { data: undefined, ok: true }, so do not rely on response.data in a delete. See src/tools/folders/delete-folder.ts.
Cross-field validation
Validate combinations inside execute and throw MCPUserError — keep the zod schema field-local. Example from create-recommendation-view.ts:
if (!!args.tag_key !== !!args.tag_value) {
throw new MCPUserError({
errors: [{ message: "tag_key and tag_value must both be provided together" }],
});
}
Tests
All unit tests live under test/, mirroring the src/ directory layout. For a tool at src/tools/<resource>/<verb>-<resource>.ts, add test/tools/<resource>/<verb>-<resource>.test.ts. Import the tool and shared helpers from src/ — do not keep tests next to source files.
Use the testTool helper from ../../../src/utils/testing (adjust ../ depth to match your test file's nesting). The shape:
import { expect } from "vitest";
import {
type ExecutionTestTableItem,
type ExtractOutputSchema,
type ExtractValidators,
type InferValidators,
requestsInOrder,
type SchemaTestTableItem,
testTool,
} from "../../../src/utils/testing";
import tool from "../../../src/tools/budgets/list-budgets";
type Validators = ExtractValidators<typeof tool>;
type OutputSchema = ExtractOutputSchema<typeof tool>;
const validArguments: InferValidators<Validators> = { /* … */ };
const argumentSchemaTests: SchemaTestTableItem<Validators>[] = [
{ name: "valid input", data: validArguments },
// poisoned-input cases use `poisonOneValue` + `dateValidatorPoisoner`
];
const executionTests: ExecutionTestTableItem<Validators, OutputSchema>[] = [
{
name: "successful call",
apiCallHandler: requestsInOrder([
{ endpoint: "/v2/widgets", params: {/* … */}, method: "GET", result: { ok: true, data: {/* … */} } },
]),
handler: async ({ callExpectingSuccess }) => {
const res = await callExpectingSuccess(validArguments);
expect(res).toEqual(/* … */);
},
},
{
name: "unsuccessful call",
apiCallHandler: requestsInOrder([
{ endpoint: "/v2/widgets", params: {/* … */}, method: "GET", result: { ok: false, errors: [{ message: "…" }] } },
]),
handler: async ({ callExpectingMCPUserError }) => {
const err = await callExpectingMCPUserError(validArguments);
expect(err.exception).toEqual({ errors: [{ message: "…" }] });
},
},
];
testTool(tool, argumentSchemaTests, executionTests);
testTool automatically verifies the tool registers with the right name/title/description/annotations, so you don't write that test by hand. Reference: test/tools/budgets/list-budgets.test.ts.
Always include:
- At least one schema test with valid input.
- A schema test per non-trivial constraint (required field missing, bad enum value, poisoned date via
dateValidatorPoisoner). - A successful-execution test that asserts both the request params (via
requestsInOrder) and the returned shape. - An unsuccessful-execution test that asserts the
MCPUserErrorexception shape.
Optional evals
Evals are opt-in. When adding or changing a tool, ask the user:
- Whether they want evals included in the change.
- If so, whether the provider API key for the model they intend to use is configured in the ignored
.envfile.
If the user declines evals, do not add or modify eval case files, result JSON, or the generated site. If they want evals but do not have the required API key configured, the case file may still be authored, but do not run it; explain the missing setup at handoff. Never infer permission to include or run evals from an existing case file or the presence of credentials.
After the user opts in, see .agents/skills/writing-evals/SKILL.md for the full guide: file template, prompt matrix, distractors, failure diagnosis, and the evals/results/<model>/ JSON workflow.
Checklist before opening a PR
- Tool file lives under
src/tools/<resource>/, not at the top level ofsrc/tools/. - Duplicated zod across tools in the family lives in
schemas.ts, not copy-pasted between files (see "Shared schemas"). -
src/tools/<resource>/index.tsimports every tool in the family. -
npm run generate-tools-indexhas been run andsrc/tools/index.tsimports./<resource>(no stale per-tool imports for that family). -
annotationsfollow the table above (especially:create-*isdestructive: false). - Description is one or two sentences plus only the non-obvious context the model needs.
- Every zod field has a
.describe(...)and uses the right helper (dateValidator,pathEncode,DEFAULT_LIMIT,paginationData,MCPUserError). - Delete tools return
{ token: args.<resource>_token }. - Tests live under
test/tools/<resource>/and cover schema validation (valid + poisoned), success, and failure. - The user was asked whether to include evals and, if they opted in, whether the provider API key they intend to use is configured in
.env. - If evals were included, the applicable checklist in
.agents/skills/writing-evals/SKILL.mdis complete. -
npm run type-checkandnpm test -- --runare green.