Writing Tools (TypeScript)
Installation
npm install protomcp zodThe tool() function
Register a tool by calling tool() with a description, Zod schema for arguments, and a handler.
import { tool, ToolResult } from 'protomcp';import { z } from 'zod';
tool({ description: 'Add two numbers', args: z.object({ a: z.number().describe('First number'), b: z.number().describe('Second number'), }), handler({ a, b }) { return new ToolResult({ result: String(a + b) }); },});The tool name is inferred from the handler function name. Use name to set it explicitly:
tool({ name: 'my_tool', description: 'A tool with an explicit name', args: z.object({ input: z.string() }), handler({ input }) { return new ToolResult({ result: input.toUpperCase() }); },});Tool metadata
Pass additional options to provide metadata hints to the MCP host:
tool({ description: 'Delete a file from disk', title: 'Delete File', destructiveHint: true, idempotentHint: false, readOnlyHint: false, openWorldHint: false, taskSupport: false, args: z.object({ path: z.string() }), async handler({ path }) { await fs.unlink(path); return new ToolResult({ result: `Deleted ${path}` }); },});| Option | Type | Default | Description |
|---|---|---|---|
description | string | required | Human-readable description |
args | z.ZodObject<any> | required | Zod schema for input arguments |
handler | (args) => any | required | Function called when the tool is invoked |
name | string | inferred | Explicit tool name |
title | string | "" | Display name shown in the MCP host UI |
destructiveHint | boolean | false | Hint: the tool has destructive side effects |
idempotentHint | boolean | false | Hint: calling the tool multiple times has the same effect as once |
readOnlyHint | boolean | false | Hint: the tool does not modify state |
openWorldHint | boolean | false | Hint: the tool may access resources outside the current context |
taskSupport | boolean | false | Hint: the tool supports long-running async task semantics |
output | z.ZodObject<any> | — | Zod schema for structured output |
Zod schemas for input validation
All argument types are defined using Zod. protomcp converts the Zod schema to JSON Schema automatically using zod-to-json-schema.
tool({ description: 'Search documents', args: z.object({ query: z.string().describe('Search query'), limit: z.number().int().min(1).max(100).default(10), includeArchived: z.boolean().default(false), }), async handler({ query, limit, includeArchived }) { const results = await search(query, { limit, includeArchived }); return new ToolResult({ result: JSON.stringify(results) }); },});Async handlers
Handlers can be async:
tool({ description: 'Fetch a URL', args: z.object({ url: z.string().url() }), async handler({ url }) { const resp = await fetch(url); const text = await resp.text(); return new ToolResult({ result: text }); },});ToolResult
class ToolResult { result: string; isError: boolean; enableTools?: string[]; disableTools?: string[]; errorCode?: string; message?: string; suggestion?: string; retryable: boolean;
constructor(options?: { result?: string; isError?: boolean; enableTools?: string[]; disableTools?: string[]; errorCode?: string; message?: string; suggestion?: string; retryable?: boolean; });}Success
return new ToolResult({ result: 'done' });Structured error
return new ToolResult({ isError: true, errorCode: 'NOT_FOUND', message: 'The file /tmp/data.csv does not exist', suggestion: 'Check the path and try again', retryable: false,});Enabling / disabling tools from a result
tool({ description: 'Log in and unlock tools', args: z.object({ username: z.string(), password: z.string() }), async handler({ username, password }) { if (!await authenticate(username, password)) { return new ToolResult({ isError: true, message: 'Authentication failed' }); } return new ToolResult({ result: 'Logged in', enableTools: ['delete_file', 'write_file'], }); },});toolManager
Use toolManager to modify the active tool list during a tool call.
import { tool, ToolResult, toolManager } from 'protomcp';import { z } from 'zod';
tool({ description: 'Enable debug tools', args: z.object({}), async handler() { const active = await toolManager.enable(['debug_dump', 'trace_calls']); return new ToolResult({ result: `Active: ${active.join(', ')}` }); },});toolManager API
All methods return Promise<string[]> — the list of currently active tool names.
toolManager.enable(toolNames: string[]): Promise<string[]>toolManager.disable(toolNames: string[]): Promise<string[]>toolManager.setAllowed(toolNames: string[]): Promise<string[]>toolManager.setBlocked(toolNames: string[]): Promise<string[]>toolManager.getActiveTools(): Promise<string[]>toolManager.batch(options: { enable?: string[]; disable?: string[]; allow?: string[]; block?: string[];}): Promise<string[]>Batch operations
const active = await toolManager.batch({ enable: ['write_file'], disable: ['read_only_mode'],});Progress Reporting
Use ToolContext to report progress during a long-running tool call. protomcp injects it — access it via the second argument to handler.
import { tool, ToolResult, ToolContext } from 'protomcp';import { z } from 'zod';
tool({ description: 'Process a large dataset', args: z.object({ filePath: z.string() }), async handler({ filePath }, ctx: ToolContext) { const rows = await loadRows(filePath); const total = rows.length; for (let i = 0; i < rows.length; i++) { if (ctx.isCancelled()) { return new ToolResult({ isError: true, message: 'Cancelled' }); } await processRow(rows[i]); ctx.reportProgress(i + 1, total, `Processing row ${i + 1}/${total}`); } return new ToolResult({ result: `Processed ${total} rows` }); },});ToolContext API
| Method | Signature | Description |
|---|---|---|
reportProgress | (progress: number, total?: number, message?: string) => void | Send a progress notification to the MCP host |
isCancelled | () => boolean | Returns true if the MCP host has cancelled this call |
reportProgress is a no-op if no progressToken was provided by the host.
Server Logging
Use ServerLogger to send structured log messages back to the MCP host.
import { tool, ToolResult, ServerLogger } from 'protomcp';import { z } from 'zod';
const logger = new ServerLogger(/* injected by protomcp */);
tool({ description: 'Fetch remote data', args: z.object({ url: z.string().url() }), async handler({ url }) { logger.info('Fetching URL', { url }); try { const resp = await fetch(url); const text = await resp.text(); logger.debug('Fetch complete', { bytes: text.length }); return new ToolResult({ result: text }); } catch (e) { logger.error('Fetch failed', { url, error: String(e) }); return new ToolResult({ isError: true, message: String(e) }); } },});ServerLogger API
All methods accept a message string and an optional data object (serialized to JSON).
| Method | Level |
|---|---|
debug(msg, data?) | debug |
info(msg, data?) | info |
notice(msg, data?) | notice |
warning(msg, data?) | warning |
error(msg, data?) | error |
critical(msg, data?) | critical |
alert(msg, data?) | alert |
emergency(msg, data?) | emergency |
Structured Output
Use output in tool() to declare a structured output schema using Zod. protomcp converts it to JSON Schema automatically.
import { tool, ToolResult } from 'protomcp';import { z } from 'zod';
const SearchResult = z.object({ title: z.string(), url: z.string(), score: z.number(),});
tool({ description: 'Search the web', output: SearchResult, args: z.object({ query: z.string() }), async handler({ query }) { const results = await runSearch(query); return new ToolResult({ result: JSON.stringify(results) }); },});Cancellation
Check ctx.isCancelled() periodically in long-running tools to stop early when the MCP host cancels the request.
import { tool, ToolResult, ToolContext } from 'protomcp';import { z } from 'zod';
tool({ description: 'Run a slow computation', args: z.object({ n: z.number().int() }), async handler({ n }, ctx: ToolContext) { let result = 0; for (let i = 0; i < n; i++) { if (ctx.isCancelled()) { return new ToolResult({ isError: true, message: 'Cancelled by host' }); } result += await expensiveStep(i); } return new ToolResult({ result: String(result) }); },});Cancellation is cooperative — protomcp sets the cancelled flag and your tool is responsible for checking it.
Testing tools
Use clearRegistry() between tests to avoid cross-test contamination:
import { tool, ToolResult, clearRegistry, getRegisteredTools } from 'protomcp';import { z } from 'zod';
beforeEach(() => clearRegistry());
test('add tool returns sum', async () => { tool({ name: 'add', description: 'Add two numbers', args: z.object({ a: z.number(), b: z.number() }), handler({ a, b }) { return new ToolResult({ result: String(a + b) }); }, });
const tools = getRegisteredTools(); expect(tools).toHaveLength(1); expect(tools[0].name).toBe('add');
const result = await tools[0].handler({ a: 2, b: 3 }); expect(result.result).toBe('5'); expect(result.isError).toBe(false);});Extended Schema Types
Zod provides built-in support for complex types that map to JSON Schema automatically:
| Zod type | JSON Schema |
|---|---|
z.array(z.string()) | {"type": "array", "items": {"type": "string"}} |
z.record(z.number()) | {"type": "object", "additionalProperties": {"type": "number"}} |
z.union([z.string(), z.number()]) | {"anyOf": [...]} |
z.enum(["a", "b"]) | {"type": "string", "enum": ["a", "b"]} |
z.string().optional() | {"type": "string"}, not required |
Tool Groups
Group related actions under a single tool using toolGroup().
import { toolGroup, ToolResult } from 'protomcp';import { z } from 'zod';
toolGroup({ name: 'files', description: 'File operations', strategy: 'union', actions: [ { name: 'read', description: 'Read a file', args: z.object({ path: z.string() }), handler({ path }) { return new ToolResult({ result: readFileSync(path, 'utf8') }); }, }, { name: 'write', description: 'Write a file', args: z.object({ path: z.string(), content: z.string() }), handler({ path, content }) { writeFileSync(path, content); return new ToolResult({ result: `Wrote ${path}` }); }, }, ],});Strategy: union (default)
With strategy: 'union', the group registers as a single tool with a discriminated oneOf schema. The caller passes an action field to select the action.
Strategy: separate
With strategy: 'separate', each action becomes its own tool, namespaced as group.action (e.g. files.read, files.write).
Dispatch and fuzzy matching
When an unknown action is passed, the dispatcher returns an error with a fuzzy “Did you mean?” suggestion.
Declarative Validation
Declare validation rules on actions to validate input before the handler runs.
requires
{ name: 'deploy', requires: ['env', 'version'], args: z.object({ env: z.string(), version: z.string() }), handler({ env, version }) { ... },}enumFields
Restrict a field to a set of valid values. Invalid values trigger a “Did you mean?” suggestion.
{ name: 'set_env', enumFields: { env: ['dev', 'staging', 'prod'] }, args: z.object({ env: z.string() }), handler({ env }) { ... },}crossRules
Validate relationships between parameters.
{ name: 'scale', crossRules: [ { check: (args) => args.min > args.max, message: 'min must be <= max' }, ], args: z.object({ min: z.number(), max: z.number() }), handler({ min, max }) { ... },}hints
Non-blocking advisory messages appended to the result when a condition is met.
{ name: 'query', hints: { slow_warning: { condition: (args) => args.limit > 1000, message: 'Large limit may cause slow queries', }, }, args: z.object({ table: z.string(), limit: z.number().default(100) }), handler({ table, limit }) { ... },}Server Context
Register resolvers that inject shared parameters into tool handlers automatically.
import { serverContext } from 'protomcp';
serverContext('project_dir', { expose: false, resolver: (args) => process.cwd(),});With expose: false, the parameter is hidden from the tool schema sent to the MCP host but still injected into the handler arguments.
Local Middleware
Wrap tool handlers with in-process middleware for cross-cutting concerns.
import { localMiddleware, ToolResult } from 'protomcp';
localMiddleware(10, async (ctx, toolName, args, next) => { const start = Date.now(); const result = await next(ctx, args); console.log(`${toolName} took ${Date.now() - start}ms`); return result;});Priority chain
Middleware is sorted by priority (lowest first = outermost). Return a ToolResult directly to short-circuit the chain.
Local vs Go-bridge middleware
Local middleware runs in-process in Node.js. Go-bridge middleware runs cross-process via the Go transport layer.
Telemetry
Observe tool calls with fail-safe telemetry sinks. Exceptions in sinks are silently swallowed.
import { telemetrySink, ToolCallEvent } from 'protomcp';
telemetrySink((event: ToolCallEvent) => { console.log(`[${event.phase}] ${event.toolName}: ${event.message}`);});ToolCallEvent phases
| Phase | When |
|---|---|
"start" | Before the handler runs |
"success" | After the handler returns successfully |
"error" | After the handler raises or returns an error |
"progress" | When reportProgress is called |
Sidecar Management
Declare companion processes that protomcp manages alongside your server.
import { sidecar } from 'protomcp';
sidecar({ name: 'redis', command: ['redis-server', '--port', '6380'], healthCheck: 'http://localhost:6380/ping', startOn: 'server_start', healthTimeout: 30000,});| Option | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique sidecar identifier |
command | string[] | required | Process command and arguments |
healthCheck | string | "" | URL to poll for health (HTTP 200 = healthy) |
startOn | string | "first_tool_call" | "server_start" or "first_tool_call" |
healthTimeout | number | 30000 | Milliseconds to wait for health check to pass |
PID files are stored in ~/.protomcp/sidecars/. Processes receive SIGTERM on shutdown, followed by SIGKILL if they do not stop within the timeout.
Handler Discovery
Auto-discover handler files from a directory instead of importing them manually.
import { configureDiscovery } from 'protomcp';
configureDiscovery({ handlersDir: './handlers', hotReload: true });- All
.tsand.jsfiles inhandlersDirare imported automatically. - Files prefixed with
_(e.g._helpers.ts) are skipped. - With
hotReload: true, previously loaded modules are cleared and re-imported on each discovery pass.