Skip to content

Building Tools

Prerequisites

Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.

Introduction

Most MCP servers force you to define tools via giant, nested JSON schemas or tangled Zod objects. A 10-line query requires 40 lines of boilerplate — hand-written schemas, manual parameter validation, explicit success() wrapping, and disconnected error handling. The result is code that nobody enjoys reading or maintaining.

Vurb.ts's Fluent API eliminates all of that. You declare what your tool does, what it needs, and how it behaves — through semantic verbs, chainable builder methods, and a terminal .handle(). The framework handles schema generation, validation, response wrapping, and type inference automatically.

The tools you build work with every MCP client — Cursor, Claude Desktop, Claude Code, Windsurf, Cline, VS Code with Copilot — and deploy unchanged to Vercel or Cloudflare Workers.

typescript
import { initVurb } from '@vurb/core';

interface AppContext {
  db: DatabaseClient;
  tenantId: string;
}

const f = initVurb<AppContext>();

export const listTasks = f.query('tasks.list')
  .describe('Lists all tasks for the current user')
  .instructions('Use when the user asks for a summary of their work.')
  .withOptionalEnum('status', ['open', 'closed'] as const, 'Filter by status')
  .returns(TaskPresenter)
  .handle(async (input, ctx) => {
    return ctx.db.tasks.findMany({
      where: { tenantId: ctx.tenantId, status: input.status },
    });
  });

Everything — input.status, ctx.db, ctx.tenantId — is fully typed, zero annotations. The handler just returns raw data; the framework wraps it with success() automatically.

Context Setup

Before building tools, define the application context — the shared state every handler receives. Pass a generic to initVurb() and store the result in a shared file:

typescript
// src/vurb.ts
import { initVurb } from '@vurb/core';

interface AppContext {
  db: DatabaseClient;
  tenantId: string;
  userId: string;
}

export const f = initVurb<AppContext>();

TIP

Import f across all your tool files. The generic parameter flows through every builder, middleware, and Presenter — zero annotations needed downstream.

Semantic Verbs

Every tool starts with a semantic verb that tells the LLM (and your team) what kind of operation it is:

typescript
// ── Query: Read-only, no side effects ──────────────────
const listUsers = f.query('users.list')
  .describe('List all users in the workspace')
  .handle(async (input, ctx) => { /* ... */ });

// ── Action: Creates or updates data (reversible) ────────
const createUser = f.action('users.create')
  .describe('Create a new user in the workspace')
  .withString('email', 'User email address')
  .handle(async (input, ctx) => { /* ... */ });

// ── Mutation: Destructive, irreversible ─────────────────
const deleteUser = f.mutation('users.delete')
  .describe('Permanently delete a user and all their data')
  .withString('id', 'User ID to delete')
  .handle(async (input, ctx) => { /* ... */ });

The LLM sees these annotations in tool descriptions: f.query() adds [READ-ONLY], f.mutation() adds [DESTRUCTIVE]. MCP clients like Claude Desktop read the annotations and show confirmation dialogs before destructive operations — no prompt engineering needed.

VerbMCP AnnotationsWhen to Use
f.query()readOnly: true, destructive: falseFetching data — lists, searches, lookups
f.action()Neutral (no flags)Creating or updating data — reversible side effects
f.mutation()destructive: trueDeleting, purging, revoking — irreversible changes

Parameter Declaration

Use chainable with*() methods instead of Zod schemas. Every method generates a proper JSON Schema under the hood:

typescript
const semanticSearch = f.query('search.semantic')
  .describe('Search across workspace using embeddings')
  .withString('query', 'The natural language search term')
  .withOptionalNumber('limit', 'Maximum number of results to return')
  .withOptionalEnum('priority', ['high', 'low', 'medium'] as const, 'Filter by priority')
  .withOptionalArray('tags', 'string', 'Filter items by tags')
  .withOptionalBoolean('active_only', 'Only search active items')
  .handle(async (input, ctx) => {
    // input.query: string          ← required
    // input.limit: number | undefined  ← optional
    // input.priority: 'high' | 'low' | 'medium' | undefined
    // input.tags: string[] | undefined
    // input.active_only: boolean | undefined
  });

Available with*() Methods

MethodRequiredTypeScript Type
.withString(name, desc)string
.withOptionalString(name, desc)string | undefined
.withNumber(name, desc)number
.withOptionalNumber(name, desc)number | undefined
.withBoolean(name, desc)boolean
.withOptionalBoolean(name, desc)boolean | undefined
.withEnum(name, values, desc)Union of values
.withOptionalEnum(name, values, desc)Union of values | undefined
.withArray(name, type, desc)T[]
.withOptionalArray(name, type, desc)T[] | undefined

Bulk Parameter Declaration v3.5.0

When a tool has many parameters of the same type, use the plural bulk variants. Each accepts a Record<string, string> where keys are parameter names and values are descriptions — zero repetition:

typescript
const filterTasks = f.query('tasks.filter')
  .describe('Filter tasks with multiple criteria')
  .withStrings({
    company_slug: 'Workspace identifier',
    project_slug: 'Project identifier',
  })
  .withOptionalStrings({
    title:    'Filter by title (partial match)',
    workflow: 'Column name (e.g. "In Progress")',
    labels:   'Comma-separated label names',
    type:     'Task type (e.g. "Bug", "Feature")',
  })
  .withOptionalBooleans({
    is_blocker: 'Only blockers',
    is_bug:     'Only bugs',
    unassigned: 'Only unassigned tasks',
  })
  .withOptionalNumbers({
    per_page: 'Results per page (default: 50)',
  })
  .withOptionalEnums({
    priority: [['low', 'medium', 'high', 'critical'], 'Priority level'],
  })
  .handle(async (input, ctx) => {
    // All fields are fully typed ✅
    // input.company_slug: string
    // input.title?: string | undefined
    // input.is_blocker?: boolean | undefined
    // input.per_page?: number | undefined
    // input.priority?: 'low' | 'medium' | 'high' | 'critical' | undefined
  });
Bulk MethodSingular Equivalent
.withStrings({ ... })Multiple .withString()
.withOptionalStrings({ ... })Multiple .withOptionalString()
.withNumbers({ ... })Multiple .withNumber()
.withOptionalNumbers({ ... })Multiple .withOptionalNumber()
.withBooleans({ ... })Multiple .withBoolean()
.withOptionalBooleans({ ... })Multiple .withOptionalBoolean()
.withEnums({ k: [values, desc?] })Multiple .withEnum()
.withOptionalEnums({ k: [values, desc?] })Multiple .withOptionalEnum()
.withArrays(itemType, { ... })Multiple .withArray()
.withOptionalArrays(itemType, { ... })Multiple .withOptionalArray()

TIP

Mix singular and bulk methods freely. Use singular for one-off required fields and bulk for groups of optional filters.

AI Instructions

.instructions() injects system-level guidance directly into the tool description. This is Prompt Engineering embedded in the framework — the LLM reads it before deciding whether and how to use the tool:

typescript
export const searchDocs = f.query('docs.search')
  .describe('Search internal documentation')
  .instructions(
    'Use ONLY when the user asks about internal policies or procedures. ' +
    'Do NOT use for general knowledge questions.'
  )
  .withString('query', 'Search term')
  .handle(async (input, ctx) => {
    return ctx.docs.search(input.query);
  });

The LLM sees:

text
[INSTRUCTIONS] Use ONLY when the user asks about internal policies or procedures.
Do NOT use for general knowledge questions.

Search internal documentation

TIP

Use .instructions() for behavioral guidance — when to use the tool, what to avoid, how to format the output. Use .describe() for what the tool does. Together they eliminate hallucinated tool calls.

Semantic Overrides & Annotations

Fine-Grained Semantic Control

Each verb sets defaults, but you can override them on any tool:

typescript
// An action that is safe to retry
const updateConfig = f.action('config.update')
  .describe('Update application configuration')
  .idempotent()   // ← safe to retry, no duplicate side effects
  .withString('key', 'Config key')
  .withString('value', 'New value')
  .handle(async (input, ctx) => { /* ... */ });

// An action that is also read-only (despite being an action verb)
const healthCheck = f.action('system.health')
  .describe('Run a system health check')
  .readOnly()     // ← override: no side effects
  .handle(async (input, ctx) => { /* ... */ });
OverrideEffect
.readOnly()Sets readOnlyHint: true in MCP annotations
.destructive()Sets destructiveHint: true — triggers confirmation dialogs
.idempotent()Sets idempotentHint: true — safe to retry

Custom MCP Annotations

For tool-specific metadata beyond the standard hints, use .annotations():

typescript
const betaFeature = f.query('beta.experimental')
  .describe('Access experimental beta features')
  .annotations({
    openWorldHint: true,
    title: 'Beta Features',
  })
  .handle(async (input, ctx) => { /* ... */ });

Capability Tags

Use .tags() for selective tool exposure. Tags let you filter tools at registration time — exposing different sets to different clients:

typescript
const adminTool = f.mutation('admin.reset')
  .describe('Reset all user sessions')
  .tags('internal', 'admin')
  .handle(async (input, ctx) => { /* ... */ });

// Later, at registration:
registry.attachToServer(server, {
  filter: { exclude: ['internal'] }, // hides admin tools from public clients
});

Connecting a Presenter

The .returns() method attaches an MVA Presenter that controls exactly what the agent sees:

typescript
import { createPresenter, t } from '@vurb/core';

const ProjectPresenter = createPresenter('Project')
  .schema({
    id:     t.string,
    name:   t.string,
    status: t.enum('active', 'archived'),
  })
  .limit(50);

export const listProjects = f.query('projects.list')
  .describe('List all projects in the workspace')
  .returns(ProjectPresenter)
  .handle(async (input, ctx) => {
    return ctx.db.projects.findMany({
      where: { tenantId: ctx.tenantId },
    });
  });

The handler returns the raw database result — a massive array with internal fields, timestamps, and IDs. The Presenter:

  1. Strips undeclared fields (egress firewall)
  2. Validates against the schema (Zod .parse())
  3. Truncates to 50 items with a warning (cognitive guardrail)
  4. Attaches system rules, UI blocks, and suggested actions

NOTE

The handler's only job is to fetch data. The Presenter does all the heavy lifting — validation, stripping, rules, affordances. This is the MVA pattern in action.

Middleware — Context Derivation

Use .use() to enrich context before it reaches the handler. The middleware receives { ctx, next } and can add properties, enforce guards, or halt execution:

typescript
export const adminStats = f.query('admin.stats')
  .describe('Retrieve administrative system statistics')
  .use(async ({ ctx, next }) => {
    const session = await checkAuth(ctx.token);
    if (!session.isAdmin) throw new Error('Unauthorized');
    return next({ ...ctx, session });
  })
  .handle(async (input, ctx) => {
    // ctx.session is fully typed — verified and ready
    return ctx.db.getStats(ctx.session.orgId);
  });

The derived ctx.session is automatically typed in the handler. Stack multiple .use() calls for layered derivations (auth → permissions → tenant resolution).

See the full Middleware guide for f.middleware(), defineMiddleware(), and composition patterns.

State Sync — Cache & Invalidation

LLMs have no sense of time. After calling sprints.list and then sprints.create, the agent still believes the list is unchanged. Inline state sync methods on the builder solve this:

typescript
// Query: reference data that never changes
const listCountries = f.query('countries.list')
  .describe('List all country codes')
  .cached()    // ← immutable, safe to cache forever
  .handle(async (input, ctx) => {
    return ctx.db.countries.findMany();
  });

// Query: volatile data that may change at any time
const listSprints = f.query('sprints.list')
  .describe('List workspace sprints')
  .stale()     // ← no-store, re-fetch before using
  .handle(async (input, ctx) => {
    return ctx.db.sprints.findMany({ where: { tenantId: ctx.tenantId } });
  });

// Mutation: invalidates cached data on success
const createSprint = f.mutation('sprints.create')
  .describe('Create a new sprint')
  .invalidates('sprints.*')  // ← tells the agent to re-read sprints
  .withString('name', 'Sprint name')
  .handle(async (input, ctx) => {
    return ctx.db.sprints.create({ data: { name: input.name } });
  });
MethodCache DirectiveUse When
.cached()immutableReference data — country codes, timezones, enums
.stale()no-storeVolatile data — always re-fetch before acting
.invalidates(...patterns)Causal signalMutations — tell the agent what data changed

See the full State Sync guide for registry-level policies, cross-domain invalidation, and observability.

Runtime Guards

Concurrency Limits

Prevent expensive tools from overwhelming your backend:

typescript
const heavyReport = f.query('analytics.heavy_report')
  .describe('Generate a comprehensive analytics report')
  .concurrency({ max: 2, queueSize: 5 })  // ← max 2 concurrent, 5 queued
  .handle(async (input, ctx) => { /* ... */ });

Egress Guards

Cap the maximum response payload to protect the LLM's context window:

typescript
const bulkExport = f.query('data.export')
  .describe('Export dataset as JSON')
  .egress(1_000_000)  // ← max 1 MB response
  .handle(async (input, ctx) => { /* ... */ });

See the Runtime Guards guide for the full configuration reference.

Streaming Progress

Long-running operations report progress via generator handlers. Each yield progress() becomes an MCP notifications/progress event:

typescript
import { progress } from '@vurb/core';

export const deploy = f.mutation('infra.deploy')
  .describe('Deploy infrastructure to the target environment')
  .withEnum('env', ['staging', 'production'] as const, 'Target environment')
  .handle(async function* (input, ctx) {
    yield progress(10, 'Cloning repository...');
    await cloneRepo(ctx.repoUrl);

    yield progress(90, 'Running integration tests...');
    const results = await runTests();

    yield progress(100, 'Done!');
    return results;
  });

TIP

The final return value goes through the normal Presenter pipeline. The yield calls are side-channel progress notifications — they don't affect the response.

See the Streaming Progress cookbook for real-world examples and cancellation support.

Registering & Serving

Once your tools are built, registration is straightforward:

typescript
import { ToolRegistry } from '@vurb/core';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const registry = new ToolRegistry();
registry.registerAll(listTasks, deleteTask, listProjects, createSprint);

const server = new McpServer({ name: 'my-app', version: '1.0.0' });

registry.attachToServer(server, {
  contextFactory: async (extra) => ({
    db: getDatabaseClient(),
    tenantId: extra.session?.tenantId ?? 'default',
    userId: extra.session?.userId ?? 'anonymous',
  }),
});

const transport = new StdioServerTransport();
await server.connect(transport);

TIP

Use autoDiscover() for file-based routing — drop tool files in a directory and they're registered automatically. See Routing & Groups for the full guide.

TIP

Test your tools with @vurb/testing — assert tool responses, measure blast radius of changes, and snapshot test Presenter output. See Testing for the full harness.

Deploy Your Tools

Every tool you built above is transport-agnostic. The Fluent API compiles tool metadata — Zod schemas, Presenter bindings, middleware chains — into a ToolRegistry that works identically on Stdio, SSE, and serverless runtimes. Moving from local development to production at the edge requires no tool code changes.

Vercel — App Router Endpoint

Export the registry as a Next.js POST handler. Edge Runtime compiles tools once at cold start; subsequent requests execute the pipeline without re-reflection:

typescript
import { vercelAdapter } from '@vurb/vercel';
export const POST = vercelAdapter({ registry, contextFactory });

Cloudflare Workers — Edge-Native SQL & KV

The adapter receives (req, env, executionCtx), giving your tool handlers access to D1 for edge-native SQL, KV for sub-millisecond reads, and waitUntil() for background telemetry:

typescript
import { cloudflareWorkersAdapter } from '@vurb/cloudflare';
export default cloudflareWorkersAdapter({ registry, contextFactory });

Full deployment guides: Vercel Adapter · Cloudflare Adapter · Production Server