Skip to content

Writing Tools (TypeScript)

Installation

Terminal window
npm install protomcp zod

The 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}` });
},
});
OptionTypeDefaultDescription
descriptionstringrequiredHuman-readable description
argsz.ZodObject<any>requiredZod schema for input arguments
handler(args) => anyrequiredFunction called when the tool is invoked
namestringinferredExplicit tool name
titlestring""Display name shown in the MCP host UI
destructiveHintbooleanfalseHint: the tool has destructive side effects
idempotentHintbooleanfalseHint: calling the tool multiple times has the same effect as once
readOnlyHintbooleanfalseHint: the tool does not modify state
openWorldHintbooleanfalseHint: the tool may access resources outside the current context
taskSupportbooleanfalseHint: the tool supports long-running async task semantics
outputz.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

MethodSignatureDescription
reportProgress(progress: number, total?: number, message?: string) => voidSend a progress notification to the MCP host
isCancelled() => booleanReturns 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).

MethodLevel
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 typeJSON 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

PhaseWhen
"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,
});
OptionTypeDefaultDescription
namestringrequiredUnique sidecar identifier
commandstring[]requiredProcess command and arguments
healthCheckstring""URL to poll for health (HTTP 200 = healthy)
startOnstring"first_tool_call""server_start" or "first_tool_call"
healthTimeoutnumber30000Milliseconds 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 .ts and .js files in handlersDir are 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.