Skip to content

Namespaces & Routing

Prerequisites

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

Introduction

Vurb.ts separates how you author tools from how the agent discovers them. You organize tools as files in a directory tree; the framework maps that tree into MCP tool definitions with clear naming, discriminators, and shared schemas to achieve ultimate Context Window Optimization. Instead of overwhelming the AI's cognitive load with 500 flat endpoints, this hierarchical routing enables Action Consolidation, reducing token consumption drastically.

This works for hand-written tools and for code generated by @vurb/openapi-gen or @vurb/prisma-gen — drop generated files into src/tools/ and autoDiscover() picks them up.

File-Based Routing — autoDiscover()

autoDiscover() scans a directory and registers all exported tools:

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

const f = initVurb<AppContext>();
const registry = f.registry();

await autoDiscover(registry, './src/tools');

Your file structure becomes the routing table:

text
src/tools/
├── billing/
│   ├── get_invoice.ts  → billing.get_invoice
│   ├── pay.ts          → billing.pay
│   └── refund.ts       → billing.refund
├── users/
│   ├── list.ts         → users.list
│   ├── invite.ts       → users.invite
│   └── ban.ts          → users.ban
└── analytics/
    └── dashboard.ts    → analytics.dashboard

Add a file — it's registered on the next start. Delete a file — it's gone. Each tool file exports a builder. autoDiscover() checks for a default export first, then a named tool export, then any value with .getName() and .buildToolDefinition():

typescript
// src/tools/billing/pay.ts
import { f } from '../../vurb';

export default f.mutation('billing.pay')
  .describe('Process a payment for an invoice')
  .withString('invoice_id', 'Invoice ID')
  .withNumber('amount', 'Payment amount')
  .handle(async (input, ctx) => {
    return await ctx.billing.charge(input.invoice_id, input.amount);
  });

Pair autoDiscover() with createDevServer() for hot-reload during development. See the DX Guide.

Fluent Router — f.router()

When multiple tools share a prefix, middleware, and tags, f.router() eliminates repetition. The router creates a naming scope — child tools inherit the prefix, middleware chain, and tags automatically:

typescript
import { f } from './vurb';

const users = f.router('users')
    .describe('User management')
    .use(requireAuth)
    .tags('core');

// Tool name: "users", action: "list" → readOnly: true by default
export const listUsers = users.query('list')
    .describe('List all users')
    .withOptionalNumber('limit', 'Max results')
    .handle(async (input, ctx) => {
        return ctx.db.users.findMany({ take: input.limit ?? 50 });
    });

// Tool name: "users", action: "invite" → neutral verb
export const inviteUser = users.action('invite')
    .describe('Invite a user by email')
    .idempotent()
    .withString('email', 'Email address')
    .handle(async (input, ctx) => {
        return ctx.invitations.send(input.email);
    });

// Tool name: "users", action: "ban" → destructive: true by default
export const banUser = users.mutation('ban')
    .describe('Permanently ban a user')
    .withString('user_id', 'User ID to ban')
    .handle(async (input, ctx) => {
        await ctx.db.users.update({ where: { id: input.user_id }, data: { banned: true } });
    });

The router supports three methods that mirror initVurb:

MethodSemantic Defaults
users.query('list')readOnly: true
users.action('invite')No defaults (neutral)
users.mutation('ban')destructive: true

TIP

Middleware added to the router via .use() runs on every child tool. Add tool-specific middleware via .use() on the individual builder:

typescript
const deleteUser = users.mutation('delete')
    .use(async ({ ctx, next }) => {
        const admin = await requireAdmin(ctx.headers);
        return next({ ...ctx, adminUser: admin });
    })
    .withString('id', 'User ID')
    .handle(async (input, ctx) => { /* ctx.adminUser is typed */ });

Discriminators

When a tool has multiple actions, the framework compiles them behind a single MCP endpoint with an enum discriminator:

jsonc
{
  "properties": {
    "action": { "type": "string", "enum": ["list", "create", "delete"] },
    "workspace_id": { "type": "string" },
    "name": { "type": "string" }
  }
}

The action field forces the agent to select a value from a constrained enum instead of guessing between semantically similar tool names. The default key is action; override it with .discriminator('operation') on the builder.

Common Schema

With createTool(), the .commonSchema() method injects common parameters into every action:

typescript
import { createTool, success } from '@vurb/core';
import { z } from 'zod';

const projects = createTool<AppContext>('projects')
  .description('Manage workspace projects')
  .commonSchema(z.object({
    workspace_id: z.string().describe('Workspace identifier'),
  }))
  .action({
    name: 'list',
    readOnly: true,
    handler: async (ctx, args) => {
      // args.workspace_id typed as string — from commonSchema
      return success(await ctx.db.projects.findMany({
        where: { workspaceId: args.workspace_id },
      }));
    },
  })
  .action({
    name: 'create',
    schema: z.object({ name: z.string() }),
    handler: async (ctx, args) => {
      return success(await ctx.db.projects.create({
        workspaceId: args.workspace_id,
        name: args.name,
      }));
    },
  });

workspace_id appears once in the compiled schema, marked as (always required) in the auto-generated description. SchemaGenerator.ts applies per-field annotations telling the LLM which fields are required for which action.

TIP

With the Fluent API (f.query() / f.mutation()), each tool defines its own params via .withString(), .withNumber(), etc. Use createTool() with .commonSchema() when you need a shared field across many actions behind a single MCP endpoint.

Hierarchical Groups

Groups organize actions into namespaces, each with its own description and middleware:

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

const platform = createTool<AppContext>('platform')
    .description('Central API for the Platform')
    .commonSchema(z.object({
        workspace_id: z.string().describe('Workspace ID'),
    }))
    .use(authMiddleware)
    .group('users', 'User management', g => {
        g.use(requireAdmin)  // Group-scoped middleware
         .action({ name: 'invite', schema: z.object({ email: z.string() }), handler: inviteUser })
         .action({ name: 'ban', destructive: true, schema: z.object({ user_id: z.string() }), handler: banUser });
    })
    .group('billing', 'Billing operations', g => {
        g.action({ name: 'refund', destructive: true, schema: z.object({ invoice_id: z.string() }), handler: issueRefund });
    });

Discriminator values become dot-notation paths: users.invite, users.ban, billing.refund. You cannot mix .action() and .group() on the same root builder — once you use .group(), all actions must live inside groups.

Tool Exposition

By default, grouped actions expand into independent flat tools on the wire: projects.listprojects_list. To keep grouped behavior (one MCP tool with a discriminator enum):

typescript
registry.attachToServer(server, { toolExposition: 'grouped' });

See the Tool Exposition Guide for the full comparison and decision guide.