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:
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:
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.dashboardAdd 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():
// 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:
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:
| Method | Semantic 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:
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:
{
"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:
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:
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.list → projects_list. To keep grouped behavior (one MCP tool with a discriminator enum):
registry.attachToServer(server, { toolExposition: 'grouped' });See the Tool Exposition Guide for the full comparison and decision guide.