Namespaces & Routing
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
File-Based Routing — autoDiscover()
autoDiscover() scans a directory and registers all exported tools:
Your file structure becomes the routing table:
Add a file — it's registered. Delete a file — it's gone. Each tool file exports a builder:
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:
Semantic Verbs
| 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.
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:
{
"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:
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:
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:
registry.attachToServer(server, { toolExposition: 'grouped' });See the Tool Exposition Guide for the full comparison.