VurbClient
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
- Introduction
- Server — Export the Router Type
- Client — Import and Call
- How It Works
- Transport
- Client Middleware
- Error Handling
- Batch Execution
- API Reference
Introduction
MCP tool calls are stringly-typed — you pass a tool name and an arguments object, and hope the shape is correct. There's no compile-time validation, no autocomplete, nothing stopping you from sending "projetcs.create" (typo) or missing a required field.
VurbClient brings tRPC-style type inference to MCP. Export a router type from the server, import it on the client — every client.execute() call gets full autocomplete and compile-time argument validation. Zero runtime cost.
Server — Export the Router Type
// server.ts
import { initVurb, createTypedRegistry } from '@vurb/core';
import type { InferRouter } from '@vurb/core';
const f = initVurb<AppContext>();
const listProjects = f.query('projects.list')
.describe('List projects')
.withString('workspace_id', 'Workspace ID')
.withOptionalEnum('status', ['active', 'archived'] as const, 'Project status')
.handle(async (input, ctx) => ctx.db.projects.findMany());
const createProject = f.mutation('projects.create')
.describe('Create a project')
.withString('workspace_id', 'Workspace ID')
.withString('name', 'Project name')
.handle(async (input, ctx) => ctx.db.projects.create(input));
const refund = f.mutation('billing.refund')
.describe('Refund an invoice')
.withString('invoice_id', 'Invoice ID')
.withNumber('amount', 'Refund amount')
.handle(async (input, ctx) => 'Refunded');
const registry = createTypedRegistry<AppContext>()(listProjects, createProject, refund);
export type AppRouter = InferRouter<typeof registry>;createTypedRegistry() is curried — first call sets TContext, second infers builder types. InferRouter is pure type-level, zero runtime cost.
Client — Import and Call
// agent.ts
import { createVurbClient } from '@vurb/core';
import type { AppRouter } from './server.js';
const client = createVurbClient<AppRouter>(transport);
const result = await client.execute('projects.create', {
workspace_id: 'ws_1',
name: 'Project V2',
});Compile-time errors for typos, missing fields, and type mismatches:
await client.execute('projects.nonexistent', {}); // TS error: invalid action
await client.execute('projects.create', { workspace_id: 'ws_1' }); // TS error: missing 'name'
await client.execute('projects.create', { workspace_id: 'ws_1', name: 42 }); // TS error: number ≠ stringHow It Works
execute() parses the dotted path and forwards as a discriminated call:
client.execute('projects.create', { workspace_id: 'ws_1', name: 'V2' })
↓
transport.callTool('projects', { action: 'create', workspace_id: 'ws_1', name: 'V2' })Transport
Any object implementing VurbTransport:
interface VurbTransport {
callTool(name: string, args: Record<string, unknown>): Promise<ToolResponse>;
}MCP SDK Client:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
const mcpClient = new Client(/* ... */);
const transport: VurbTransport = {
callTool: (name, args) => mcpClient.callTool({ name, arguments: args }),
};
const client = createVurbClient<AppRouter>(transport);Direct Registry (testing):
const transport: VurbTransport = {
callTool: (name, args) => registry.routeCall(testContext, name, args),
};
const client = createVurbClient<AppRouter>(transport);Client Middleware
Onion-pattern interceptors for every outgoing call:
import type { ClientMiddleware } from '@vurb/core';
const authMiddleware: ClientMiddleware = async (action, args, next) => {
return next(action, { ...args, _token: await getToken() });
};
const logMiddleware: ClientMiddleware = async (action, args, next) => {
console.log(`→ ${action}`, args);
const result = await next(action, args);
console.log(`← ${action}`, result.isError ? 'ERROR' : 'OK');
return result;
};
const client = createVurbClient<AppRouter>(transport, {
middleware: [authMiddleware, logMiddleware],
});Compiled once at creation — O(1) per call.
Error Handling
Enable throwOnError to parse <tool_error> XML into VurbClientError:
import { createVurbClient, VurbClientError } from '@vurb/core';
const client = createVurbClient<AppRouter>(transport, { throwOnError: true });
try {
await client.execute('billing.get_invoice', { id: 'inv_999' });
} catch (err) {
if (err instanceof VurbClientError) {
err.code; // 'NOT_FOUND'
err.message; // 'Invoice inv_999 not found.'
err.recovery; // 'Call billing.list first.'
err.availableActions; // ['billing.list']
err.severity; // 'error'
err.raw; // original ToolResponse
}
}code, message, recovery, availableActions, severity, and raw are all extracted from the XML envelope. XML entities are auto-unescaped.
Batch Execution
const results = await client.executeBatch([
{ action: 'projects.list', args: { status: 'active' } },
{ action: 'billing.get_invoice', args: { id: 'inv_42' } },
{ action: 'users.me', args: {} },
]);Parallel by default (Promise.all). Use { sequential: true } for ordered execution. Middleware and throwOnError apply to every call.
API Reference
Runtime: createVurbClient(transport, options?), createTypedRegistry<TContext>(), VurbClientError.
Types: VurbClient<TRouter>, VurbTransport, InferRouter<T>, TypedToolRegistry<TContext, TBuilders>, ClientMiddleware ((action, args, next) => Promise<ToolResponse>), VurbClientOptions ({ middleware?, throwOnError? }), RouterMap.