Skip to content

Tool Exposition Strategies

You might define three actions — list, create, delete — inside one projects builder. Should the agent see one tool with a discriminator, or three independent tools? Tool Exposition decouples authoring from wire presentation.

Flat — One Tool per Action

The default. Every action becomes an independent MCP tool with its own name, schema, and annotations:

typescript
const listProjects = f.query('projects.list')
  .withString('workspace_id', 'Workspace ID')
  .handle(async (input, ctx) => { /* ... */ });

const createProject = f.action('projects.create')
  .withString('workspace_id', 'Workspace ID')
  .withString('name', 'Project name')
  .handle(async (input, ctx) => { /* ... */ });

const deleteProject = f.mutation('projects.delete')
  .withString('workspace_id', 'Workspace ID')
  .withString('id', 'Project ID')
  .handle(async (input, ctx) => { /* ... */ });

Produces three entries in tools/list:

jsonc
// projects_list — only listing fields
{
  "name": "projects_list",
  "description": "[READ-ONLY] List projects (projects → list)",
  "annotations": { "readOnlyHint": true, "destructiveHint": false },
  "inputSchema": {
    "properties": { "workspace_id": { "type": "string" } },
    "required": ["workspace_id"]
  }
}

// projects_delete — explicit destructive signal
{
  "name": "projects_delete",
  "description": "[DESTRUCTIVE] Delete project (projects → delete)",
  "annotations": { "destructiveHint": true },
  "inputSchema": {
    "properties": {
      "workspace_id": { "type": "string" },
      "id": { "type": "string" }
    },
    "required": ["workspace_id", "id"]
  }
}

Each action gets its own schema — projects_list doesn't include id. Vurb.ts explicitly emits destructiveHint: false on non-destructive actions to prevent unnecessary confirmation dialogs (the MCP spec defaults it to true).

O(1) Dispatch

At build time, a hash map is constructed: "projects_list" → { builder, actionKey: "list" }. Routing is a single Map.get(name). Handlers don't change.

Grouped — One Tool, Many Actions

All actions behind a single MCP tool with a discriminator enum:

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

Same definition, one tool:

jsonc
{
  "name": "projects",
  "description": "Manage workspace projects\n\nActions:\n- list (read-only)\n- create\n- delete (⚠️ destructive)",
  "inputSchema": {
    "properties": {
      "action": { "enum": ["list", "create", "delete"] },
      "workspace_id": { "type": "string" },
      "name": { "type": "string" },
      "id": { "type": "string" }
    },
    "required": ["action", "workspace_id"]
  }
}

Shared fields appear once instead of once per action. For 20+ actions sharing workspace_id, session_id, and admin_token, this saves significant tokens. The trade-off: the agent sees all fields from all actions in one schema.

When Grouped Shines

typescript
const admin = createTool('admin')
  .description('SaaS administration panel')
  .group('users', 'User lifecycle management', (g) => g
    .query('list', listUsers)
    .action('invite', inviteUser)
    .mutation('deactivate', deactivateUser)
  )
  .group('billing', 'Billing and subscription management', (g) => g
    .query('current_plan', getCurrentPlan)
    .action('upgrade', upgradePlan)
    .query('invoices', listInvoices)
    .mutation('refund', issueRefund)
  )
  .group('audit', 'Compliance and audit trail', (g) => g
    .query('logs', getAuditLogs)
    .query('export', exportLogs)
  );

One tool, 10 actions, workspace_id and admin_token appear once.

Configuration

typescript
registry.attachToServer(server, {
  toolExposition: 'flat',     // 'flat' (default) or 'grouped'
  actionSeparator: '_',       // flat naming: 'projects_list'
});

Switching strategies never changes your handlers — only the wire format.

Interaction with Other Features

State Sync — invalidation policies use dot-notation regardless of exposition strategy. The framework translates flat wire names back to canonical keys before policy resolution:

typescript
registry.attachToServer(server, {
  toolExposition: 'flat',
  stateSync: {
    policies: [
      { match: 'projects.*', invalidates: ['projects.*'] },
      { match: 'tasks.update', invalidates: ['tasks.*', 'sprints.*'] },
    ],
  },
});

Tag Filtering — tags are resolved from the builder, not individual flat tools:

typescript
const admin = f.query('admin.list')
  .tags('internal')
  .handle(async (input, ctx) => { /* ... */ });

const search = f.query('search.find')
  .tags('public')
  .handle(async (input, ctx) => { /* ... */ });

registry.attachToServer(server, {
  toolExposition: 'flat',
  filter: { exclude: ['internal'] },
});

Decision Guide

ScenarioStrategyWhy
Simple CRUD (3–5 actions)flatClear single-purpose tools
Per-action RBACflatClients can toggle individual tools
Smaller modelsflatAvoids enum disambiguation
Large domain (20+ actions, shared params)groupedToken savings
Enterprise platform (100+ endpoints)groupedDomain cohesion
Frontier models (GPT-4, Claude, Gemini)EitherBoth work well