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.

TELL YOUR AI AGENT
"Create a query tool with Zod validation, runtime guards, semantic verb annotations, and a Presenter for the response schema."

FLUENT API
Declare intent, not infrastructure.
Schema, validation, types — all automatic.
Semantic verbs, chainable builders, and a terminal .handle(). Your AI agent produces this from SKILL.md — works with every MCP client.

Quick Example

agents/tasks.tool.ts
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 returns raw data; the framework wraps it with success() automatically.

Context Setup

Define the shared state every handler receives. Pass a generic to initVurb() and import f across all tool files:

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

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

export const f = initVurb<AppContext>();

TIP

The generic parameter flows through every builder, middleware, and Presenter — zero annotations needed downstream.


SEMANTIC VERBS
Query. Action. Mutation.
The LLM knows the intent.

Semantic Verbs

three verbs, three intentions
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) => { /* ... */ });
VerbMCP AnnotationsWhen to Use
f.query()readOnly: trueFetching data — lists, searches, lookups
f.action()Neutral (no flags)Creating or updating — reversible side effects
f.mutation()destructive: trueDeleting, purging, revoking — irreversible

MCP clients like Claude Desktop read these annotations and show confirmation dialogs before destructive operations — no prompt engineering needed.

Parameter Declaration

Chainable with*() methods replace Zod schemas. Every method generates proper JSON Schema under the hood:

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

Bulk Parameters v3.5.0

When a tool has many parameters of the same type, bulk variants accept a Record<string, string>:

bulk declaration — 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")',
  })
  .withOptionalBooleans({
    is_blocker: 'Only blockers',
    unassigned: 'Only unassigned tasks',
  })
  .handle(async (input, ctx) => {
    // All fields fully typed ✅
  });

TIP

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

Model-Driven Parameters v3.6.0

When input fields map to a domain entity, .fromModel() reads the Model's fillable profile and generates the schema:

model-driven params
typescript
// Create — all fillable('create') fields are required
export const createTask = f.action('tasks.create')
  .describe('Create a new task')
  .fromModel(TaskModel, 'create')
  .returns(TaskPresenter)
  .handle(async (input, ctx) => {
    return ctx.db.tasks.create({ data: input });
  });

// Update — all fillable('update') fields are optional
export const updateTask = f.action('tasks.update')
  .describe('Update an existing task')
  .fromModel(TaskModel, 'update')
  .withString('task_uuid', 'Task identifier')
  .returns(TaskPresenter)
  .handle(async (input, ctx) => {
    return ctx.db.tasks.update(input.task_uuid, input);
  });
OperationField OptionalityUse Case
'create'All requiredCreating a new entity
'update'All optionalPartial updates
'filter'All optionalSearch / list filters

AI Instructions

.instructions() injects system-level guidance into the tool description — prompt engineering embedded in the framework:

behavioral guidance
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);
  });

TIP

Use .instructions() for when to use the tool. Use .describe() for what the tool does. Together they eliminate hallucinated tool calls.

Semantic Overrides & Annotations

.readOnly()
Sets readOnlyHint: true — override any verb to declare no side effects.
.destructive()
Sets destructiveHint: true — triggers confirmation dialogs in MCP clients.
.idempotent()
Sets idempotentHint: true — safe to retry, no duplicate side effects.

Use .tags('internal', 'admin') for selective tool exposure and .annotations({ openWorldHint: true }) for custom MCP metadata.

Connecting a Presenter

.returns() attaches a Presenter that controls exactly what the agent sees:

Presenter + Tool integration
typescript
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 raw database data. The Presenter strips undeclared fields, validates with Zod, truncates to 50 items, and attaches rules and affordances. See the full Presenter guide.


PRODUCTION FEATURES
Middleware. Guards. Streaming.
All inline on the builder.

Middleware — Context Derivation

.use() enriches context before it reaches the handler. Derived properties are automatically typed:

auth middleware
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 ✅
    return ctx.db.getStats(ctx.session.orgId);
  });

Stack multiple .use() calls for layered derivations (auth → permissions → tenant). See the full Middleware guide.

State Sync — Cache & Invalidation

cache directives
typescript
// Reference data — safe to cache forever
const listCountries = f.query('countries.list')
  .describe('List all country codes')
  .cached()
  .handle(async (input, ctx) => ctx.db.countries.findMany());

// Volatile data — always re-fetch
const listSprints = f.query('sprints.list')
  .describe('List workspace sprints')
  .stale()
  .handle(async (input, ctx) => ctx.db.sprints.findMany());

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

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

Runtime Guards

CONCURRENCY
Concurrency Limits
Prevent expensive tools from overwhelming your backend. .concurrency({ max: 2, queueSize: 5 })
EGRESS
Egress Guards
Cap max response payload to protect the LLM's context window. .egress(1_000_000)

See the Runtime Guards guide for the full reference.

Streaming Progress

Long-running operations report progress via generator handlers:

async generator + progress
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')
  .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 goes through the normal Presenter pipeline. yield calls are side-channel progress notifications.


DEPLOYMENT
Register. Serve. Deploy.
Same code, any runtime.

Registering & Serving

src/server.ts
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.

Deploy Your Tools

Every tool is transport-agnostic. The same ToolRegistry works on Stdio, SSE, and serverless runtimes.


Cookbook Recipes