Skip to content

Advanced Configuration

Prerequisites

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

TOON Token Compression

Vurb.ts generates Markdown descriptions for tool actions by default. .toonDescription() switches to Token-Oriented Object Notation (TOON) — a pipe-delimited table format that preserves LLM structural perception at roughly half the tokens:

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

const f = initVurb<AppContext>();

const adminUsers = f.query('admin.list_users')
  .describe('List all platform users')
  .toonDescription()
  .handle(async (input, ctx) => { /* ... */ });

const adminProvision = f.action('admin.provision_user')
  .describe('Provision a new user')
  .toonDescription()
  .withString('email', 'User email')
  .handle(async (input, ctx) => { /* ... */ });

A 40-action tool in Markdown can consume 2000+ tokens of system prompt. TOON compresses the same routing information into a single dense table.

Tag-Based Filtering

Assign tags to classify tools, then filter which ones appear in tools/list:

typescript
const githubTool = f.query('github.list_repos')
  .describe('List GitHub repositories')
  .tags('public', 'dev', 'repo')
  .handle(async (input, ctx) => { /* ... */ });

const billingTool = f.query('billing.list_invoices')
  .describe('List invoices')
  .tags('internal', 'payments')
  .handle(async (input, ctx) => { /* ... */ });
typescript
registry.attachToServer(server, {
  contextFactory: createAppContext,
  filter: {
    tags: ['public'],        // AND — tool must have ALL these tags
    anyTag: ['dev', 'repo'], // OR  — tool must have ANY of these tags
    exclude: ['payments'],   // NOT — exclude tools with ANY of these tags
  },
});

A public chat assistant never sees the billing tool. The LLM can't call what it doesn't know exists. Filters compose: a tool must pass tags AND anyTag, then survive exclude.

Custom Discriminator

The default routing field is "action". Some domains have their own vocabulary. Use createTool() to set a custom discriminator at the group level:

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

const storage = createTool<AppContext>('storage')
  .discriminator('operation')
  .action({ name: 'upload', handler: uploadHandler })
  .action({ name: 'download', handler: downloadHandler });

The LLM now sends { "operation": "upload", ... }. The compiled schema, description, and validation all reflect the new field name.

NOTE

Custom discriminators apply to the grouped tool pattern. With the Fluent API's flat approach (f.query('storage.upload')), each action is its own tool — no discriminator needed.

MCP Annotations

The MCP specification defines Annotations — UI hints for AI clients like Claude Desktop and Cursor.

Vurb.ts infers readOnlyHint and destructiveHint from semantic verbs automatically. Override explicitly on any tool:

typescript
const database = f.query('database.query')
  .describe('Run a database query')
  .annotations({
    readOnlyHint: true,
    openWorldHint: true,
  })
  .withString('sql', 'SQL query')
  .handle(async (input, ctx) => { /* ... */ });

Available annotations: readOnlyHint (no state modification), destructiveHint (irreversible changes), idempotentHint (safe to repeat), openWorldHint (interacts with external systems).

TIP

You rarely need to set annotations manually — f.query() sets readOnlyHint: true, f.mutation() sets destructiveHint: true. Use .annotations() for openWorldHint or when a semantic verbs default doesn't match your use case.