This module provides type-safe builders and factories for composing MCP tool servers using a declarative registry pattern.
The MCP protocol allows servers to expose tools that clients (like Claude) can discover and invoke. This module extracts the tool registry pattern into reusable building blocks so that:
- Type safety is automatic: Handler parameters are validated and properly typed via
Ts<T>— no manual casting needed. - Composition is declarative: Define tool entries with metadata and handlers; compose them into a registry.
- Boilerplate is eliminated: Core dispatch logic lives in
fromRegistry, not duplicated across servers. - Consistency is enforced: All MCP servers follow the same declarative pattern.
A single declarative tool entry that combines:
name: The tool identifier (used intools/callrequests)description: Human-readable description fortools/listinputRtti: Runtime type info for input validationhandle: A type-safe handler receiving pre-validated arguments
type ToolEntry<O extends Operation> = {
readonly name: string
readonly description: string
readonly inputRtti: Type
readonly handle: (args: Unknown) => Effect<O, ToolsCallResult>
}
Creates a type-safe tool entry by binding an RTTI schema with a handler. The builder validates arguments at runtime and passes pre-validated, properly-typed arguments to the handler.
const toolEntry = <T extends Type, O extends Operation>(
name: string,
description: string,
inputRtti: T,
handle: (args: Ts<T>) => Effect<O, ToolsCallResult>
): ToolEntry<O>
Benefits:
- The handler parameter type
Ts<T>is enforced at compile time. - Validation happens internally — no manual
validate()calls or type assertions in handlers. - Error results are constructed consistently.
Builds complete McpHandlers<O> from a registry of tool entries. Generates:
toolsList: Converts entries into MCPTooldescriptors with JSON schemastoolsCall: Dispatches by tool name and delegates to the appropriate handler
export const fromRegistry = <O extends Operation>(
registry: readonly ToolEntry<O>[],
): McpHandlers<O>
Define argument schemas as RTTI:
import { string, number, option, or } from '../../rtti/module.f.mjs'
const addArgs = { a: number, b: number } as const
const greetArgs = { name: string, greeting: or(option, string) } as const
Create tool entries with type-safe handlers:
import { toolEntry, errorResult } from './module.f.mjs'
const addTool = toolEntry(
'add',
'Add two numbers and return the result',
addArgs,
({ a, b }) => pure({ content: [{ type: 'text', text: `${a + b}` }] })
)
const greetTool = toolEntry(
'greet',
'Greet someone by name',
greetArgs,
({ name, greeting }) => {
const msg = `${greeting || 'Hello'}, ${name}!`
return pure({ content: [{ type: 'text', text: msg }] })
}
)
Compose into a registry and build handlers:
import { fromRegistry, mcpStep } from './module.f.mjs'
import type { McpHandlers } from './types.ts'
const myHandlers = fromRegistry([addTool, greetTool])
// Ready to use with mcpStep and your MCP transport
Tool-level errors are returned in-band via isError: true. Use the errorResult helper:
import { errorResult } from './module.f.mjs'
const safeTool = toolEntry(
'example',
'An example tool',
someArgs,
(args) => {
if (!someValidation(args)) {
return pure(errorResult('validation failed: ...'))
}
return doWork(args)
}
)
A tool whose whole body is one fallible call does not write that dispatch out. toolResultStep is the Result → ToolsCallResult adapter — it renders the ok value, renders the failure as an isError result, and empties the error channel, so the handler is a single line:
import { toolResultStep } from './module.f.mjs'
const headTool = toolEntry(
'evo_head',
'The current head hashes of a subject, one per line',
evoHeadArgs,
({ subject }) => toolResultStep(e.head(subject), hs => hs.join('\n'), evoSummary),
)
Both renderers are required. The error one is what pins the handler to a particular error channel, so a fallible call added upstream becomes a compile error at the tool that has to say what the new failure reads like — the reason unwrapStep (fjs/effects/module.f.mjs) asks for its summary.
A tool whose chain goes on after the call succeeds uses toolStep instead: the failure is the same isError result, and the ok value goes to a continuation that answers the call itself. errorText comes first, so the refusal reads beside the call it guards:
toolStep(c.write(content), () => 'write', hash => resultStep(
syncRevision(cacheKey)(hash)(content),
() => pureOk(okResult(vecToCBase32(hash)))))
The content-addressable store MCP adapter (fjs/mcp/cas/module.f.mjs) demonstrates the pattern in production:
- Define argument schemas (
casAddArgs,casGetArgs,casListArgs) - Build a registry with
toolEntryand type-safe handlers - Pass the registry to
fromRegistryto get complete handlers - Compose with
mcpStepandstdioTransportto run the server
This replaces ~100 lines of manual toolsList/toolsCall boilerplate with a single declarative registry.
Instead of manually implementing McpHandlers<O>:
// ❌ Old pattern (don't do this)
export const myHandlers: McpHandlers<O> = {
toolsList: () => pure({ tools: [...] }),
toolsCall: ({ name, arguments: args }) => {
switch (name) {
case 'tool1':
const [t, validated] = validate(tool1Args)(args)
if (t === 'error') return pure(errorResult(...))
return handle1(validated)
// ... repeated for every tool
}
}
}
Use the declarative pattern:
// ✅ New pattern (use this)
const registry: readonly ToolEntry<O>[] = [
toolEntry('tool1', 'description', tool1Args, handle1),
toolEntry('tool2', 'description', tool2Args, handle2),
// ...
]
export const myHandlers = fromRegistry(registry)
The factory handles validation, dispatch, and error wrapping transparently.
When you write:
const myTool = toolEntry(
'add',
'Add numbers',
{ x: number, y: number },
({ x, y }) => {
// x and y are inferred as `number` — no casting needed
return pure({ content: [{ type: 'text', text: `${x + y}` }] })
}
)
TypeScript ensures that:
- The handler parameter type is
{ x: number, y: number }, notUnknown - Any attempt to access undefined fields or use wrong types is caught at compile time
- The RTTI schema and handler type are kept in sync by construction
The toolEntry builder:
- Takes the RTTI schema
Tand a handler expectingTs<T>(the TypeScript type) - Returns a
ToolEntry<O>with a wrapper that validates at runtime - The wrapper calls
validate(inputRtti)and casts the result asTs<T>
This pattern is safe because:
- Validation is proven to produce
Ts<T>(by construction of the RTTI schemas) - TypeScript enforces the contract at the callsite
- The type system doesn't see the internal
as Ts<T>cast — it's encapsulated
The registry pattern composes naturally:
// Merge registries from multiple modules
const combinedRegistry = [
...coreTools,
...extendedTools,
] as const
export const handlers = fromRegistry(combinedRegistry)
Future enhancements could include:
- Runtime validation that registry entries are unique by name
- Middleware/interceptors for logging, caching, or auth
- Dynamic registry merging or hot-reloading
- Pagination support for large registries