# Writing MCP Tools

> Author a new MCP tool for the Vantage MCP server — file layout under resource folders, registerTool template, annotation hints, description style, and tests. Use whenever adding, splitting, refactoring, or relocating a tool under `src/tools/`. Evals are optional; see writing-evals when the user opts in.

- Skill: `vantage-sh/writing-mcp-tools` (Agent Skill)
- Install (CLI): `npx skillmds add vantage-sh/writing-mcp-tools`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vantage-sh/writing-mcp-tools/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: vantage-sh (https://skillmd.com/u/vantage-sh)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/vantage-sh/writing-mcp-tools

---


# 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 `./schemas` in 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. `costReportSettingsForCreate` and `costReportSettingsForUpdate`) rather than maintaining two nearly identical inline copies.
- Add or extend `schemas.ts` in the **same PR** as a new create/update pair when you introduce the second file that would duplicate an existing definition.
- `schemas.ts` is not imported from `index.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:

```ts
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.callVantageApi` accepts 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:

```ts
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.ts` calling 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 for `query-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:

```ts
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/zod` for any Vantage `*_token` arg. Validates the prefix and builds the describe string (`Workspace token (\`wrkspc_*\`).`). Pass `{ description }` for contextual prose. Prefixes live in `../../utils/zod/token-kinds.ts` and 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/zod` instead of `z.string().min(1)` for free-text fields (trims before checking length).
- `dateValidator("…")` from `../../utils/dateValidator` for any `YYYY-MM-DD` field.
- `DEFAULT_LIMIT` from `../structure/constants` when paginating.
- `paginationData(response.data)` from `../../utils/paginationData` to compute `{ hasNextPage, nextPage }` for list tools.
- `pathEncode` from `@vantage-sh/vantage-client` for any token interpolated into a URL path.
- Request body types: `RequestBodyForPathAndMethod<"/v2/…", "POST">` from `@vantage-sh/vantage-client` when you need to assert the body shape (see `create-recommendation-view.ts`).
- Shared sub-schemas go in `src/tools/<resource>/schemas.ts` when used by more than one tool in that folder (see "Shared schemas"). Import with `from "./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:

```ts
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

```ts
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`:

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

```ts
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 `MCPUserError` exception shape.

## Optional evals

Evals are opt-in. When adding or changing a tool, ask the user:

1. Whether they want evals included in the change.
2. If so, whether the provider API key for the model they intend to use is configured in the ignored `.env` file.

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 of `src/tools/`.
- [ ] Duplicated zod across tools in the family lives in `schemas.ts`, not copy-pasted between files (see "Shared schemas").
- [ ] `src/tools/<resource>/index.ts` imports every tool in the family.
- [ ] `npm run generate-tools-index` has been run and `src/tools/index.ts` imports `./<resource>` (no stale per-tool imports for that family).
- [ ] `annotations` follow the table above (especially: `create-*` is `destructive: 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.md` is complete.
- [ ] `npm run type-check` and `npm test -- --run` are green.

