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.

TELL YOUR AI AGENT
"Set up file-based routing with tool groups — billing, users, analytics — with shared auth middleware and action discriminators."

CONTEXT WINDOW OPTIMIZATION
500 flat endpoints → 5 smart tools.
Action consolidation.
Instead of overwhelming the AI with hundreds of endpoints, Vurb hierarchically groups tools with discriminators — drastically reducing token consumption.

File-Based Routing — autoDiscover()

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

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

directory → MCP tool names
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. Delete a file — it's gone. Each tool file exports a builder:

src/tools/billing/pay.ts
typescript
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 vurb dev for hot-reload. See the HMR Dev Server.

Fluent Router — f.router()

When multiple tools share a prefix, middleware, and tags:

src/routers/users.ts
typescript
const users = f.router('users')
    .describe('User management')
    .use(requireAuth)
    .tags('core');

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

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

Semantic Verbs

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.

Discriminators

When a tool has multiple actions, the framework compiles them behind a single MCP endpoint with an enum discriminator — reducing the LLM's cognitive load:

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

The action field forces the agent to select from a constrained enum instead of guessing between similar tool names.

Common Schema

With createTool(), inject common parameters into every action:

typescript
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) => {
      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,
      }));
    },
  });

TIP

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

Hierarchical Groups

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

typescript
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)
         .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.

Tool Exposition

By default, grouped actions expand into independent flat tools. To keep grouped behavior:

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

See the Tool Exposition Guide for the full comparison.


Next Steps