@outfitter/mcp
v0.5.0
Published
MCP server framework with typed tools for Outfitter
Maintainers
Readme
@outfitter/mcp
MCP (Model Context Protocol) server framework with typed tools and Result-based error handling.
Installation
bun add @outfitter/mcpQuick Start
import { createMcpServer, defineTool } from "@outfitter/mcp";
import { Result } from "@outfitter/contracts";
import { z } from "zod";
const server = createMcpServer({
name: "calculator",
version: "1.0.0",
});
server.registerTool(
defineTool({
name: "add",
description: "Add two numbers together",
inputSchema: z.object({
a: z.number(),
b: z.number(),
}),
handler: async (input, ctx) => {
ctx.logger.debug("Adding numbers", { a: input.a, b: input.b });
return Result.ok({ sum: input.a + input.b });
},
})
);
await server.start();Features
- Typed Tools — Define tools with Zod schemas for automatic input validation
- Result-Based Errors — All operations return
Result<T, E>for explicit error handling - Handler Contract — Tools use the same
Handlerpattern as other Outfitter packages - Core Tools — Built-in docs, config, and query tools for common patterns
- Deferred Loading — Support for MCP tool search with
deferLoadingflag
API Reference
createMcpServer(options)
Creates an MCP server instance.
interface McpServerOptions {
name: string; // Server name for MCP handshake
version: string; // Server version (semver)
logger?: Logger; // Optional structured logger (BYO)
defaultLogLevel?: McpLogLevel | null; // Default log forwarding level
}
const server = createMcpServer({
name: "my-server",
version: "1.0.0",
});
// If `logger` is omitted, Outfitter logger factory defaults are used.Bring Your Own Logger (BYO)
createMcpServer accepts any logger implementing the shared Logger contract.
This lets you use the default Outfitter backend or a custom backend adapter.
Outfitter factory backend
import { createOutfitterLoggerFactory } from "@outfitter/logging";
const loggerFactory = createOutfitterLoggerFactory();
const server = createMcpServer({
name: "my-server",
version: "1.0.0",
logger: loggerFactory.createLogger({
name: "mcp",
context: { surface: "mcp" },
}),
});Custom adapter backend
import {
createLoggerFactory,
type Logger,
type LoggerAdapter,
} from "@outfitter/contracts";
type BackendOptions = { write: (line: string) => void };
const adapter: LoggerAdapter<BackendOptions> = {
createLogger(config) {
const write = config.backend?.write ?? (() => {});
const createMethod = (level: string): Logger["info"] =>
((message: string) => {
write(`[${level}] ${config.name}: ${message}`);
}) as Logger["info"];
return {
trace: createMethod("trace"),
debug: createMethod("debug"),
info: createMethod("info"),
warn: createMethod("warn"),
error: createMethod("error"),
fatal: createMethod("fatal"),
child: (childContext) =>
adapter.createLogger({
...config,
context: { ...(config.context ?? {}), ...childContext },
}),
};
},
};
const loggerFactory = createLoggerFactory(adapter);
const server = createMcpServer({
name: "my-server",
version: "1.0.0",
logger: loggerFactory.createLogger({
name: "mcp",
backend: { write: (line) => console.log(line) },
}),
});Log Forwarding
MCP servers can forward log messages to the connected client. The default log level is resolved from environment configuration:
Precedence (highest wins):
OUTFITTER_LOG_LEVELenvironment variableoptions.defaultLogLevelOUTFITTER_ENVprofile defaults ("debug"in development,nullotherwise)null(no forwarding)
const server = createMcpServer({
name: "my-server",
version: "1.0.0",
// Forwarding level auto-resolved from OUTFITTER_ENV
});
// With OUTFITTER_ENV=development → forwards at "debug"
// With OUTFITTER_ENV=production → no forwarding (null)
// With OUTFITTER_LOG_LEVEL=error → forwards at "error"Set defaultLogLevel: null to explicitly disable forwarding regardless of environment. The MCP client can always override via logging/setLevel.
sendLogMessage(level, data, loggerName?)
Send a log message to the connected MCP client.
server.sendLogMessage("info", "Indexing complete", "my-server");
server.sendLogMessage("warning", { message: "Rate limited", retryAfter: 30 });Only sends if the message level meets or exceeds the current client log level threshold.
defineTool(definition)
Helper for defining typed tools with better type inference.
interface ToolDefinition<TInput, TOutput, TError> {
name: string; // Unique tool name (kebab-case)
description: string; // Human-readable description
inputSchema: z.ZodType<TInput>; // Zod schema for validation
handler: Handler<TInput, TOutput, TError>;
deferLoading?: boolean; // Default: true
}
const getUserTool = defineTool({
name: "get-user",
description: "Retrieve a user by their unique ID",
inputSchema: z.object({ userId: z.string().uuid() }),
handler: async (input, ctx) => {
const user = await db.users.find(input.userId);
if (!user) {
return Result.err(NotFoundError.create("user", input.userId));
}
return Result.ok(user);
},
});defineResource(definition)
Helper for defining MCP resources.
interface ResourceDefinition {
uri: string; // Unique resource URI
name: string; // Human-readable name
description?: string; // Optional description
mimeType?: string; // Content MIME type
handler?: ResourceReadHandler; // Optional resources/read handler
}
const configResource = defineResource({
uri: "file:///etc/app/config.json",
name: "Application Config",
description: "Main configuration file",
mimeType: "application/json",
handler: async (uri, ctx) => {
ctx.logger.debug("Reading config resource", { uri });
return Result.ok([
{
uri,
mimeType: "application/json",
text: JSON.stringify({ debug: true }),
},
]);
},
});Registered resources with handlers are exposed through MCP resources/read.
server.registerResource(configResource);
const contentResult = await server.readResource("file:///etc/app/config.json");defineResourceTemplate(definition)
Helper for defining MCP resource templates with URI pattern matching and optional Zod validation.
import { defineResourceTemplate } from "@outfitter/mcp";
import { z } from "zod";
// Basic template with string variables
const userTemplate = defineResourceTemplate({
uriTemplate: "db:///users/{userId}/profile",
name: "User Profile",
handler: async (uri, variables, ctx) => {
const profile = await getProfile(variables.userId);
return Result.ok([
{ uri, text: JSON.stringify(profile), mimeType: "application/json" },
]);
},
});
// Typed template with Zod validation and coercion
const postTemplate = defineResourceTemplate({
uriTemplate: "db:///users/{userId}/posts/{postId}",
name: "User Post",
paramSchema: z.object({
userId: z.string().min(1),
postId: z.coerce.number().int().positive(),
}),
handler: async (uri, params, ctx) => {
// params is { userId: string; postId: number } — validated and coerced
const post = await getPost(params.userId, params.postId);
return Result.ok([{ uri, text: JSON.stringify(post) }]);
},
});
server.registerResourceTemplate(userTemplate);
server.registerResourceTemplate(postTemplate);Templates use RFC 6570 Level 1 URI templates ({param} placeholders). Typed templates validate extracted variables against a Zod schema before handler invocation.
Server Methods
interface McpServer {
readonly name: string;
readonly version: string;
// Registration
registerTool<TInput, TOutput, TError>(tool: ToolDefinition): void;
registerResource(resource: ResourceDefinition): void;
registerResourceTemplate(template: ResourceTemplateDefinition): void;
// Introspection
getTools(): SerializedTool[];
getResources(): ResourceDefinition[];
getResourceTemplates(): ResourceTemplateDefinition[];
// Invocation
readResource(uri: string): Promise<Result<ResourceContent[], McpError>>;
invokeTool<T>(
name: string,
input: unknown,
options?: InvokeToolOptions
): Promise<Result<T, McpError>>;
// Lifecycle
start(): Promise<void>;
stop(): Promise<void>;
}McpHandlerContext
Extended handler context for MCP tools with additional metadata:
interface McpHandlerContext extends HandlerContext {
toolName?: string; // Name of the tool being invoked
}Streaming and Progress (@outfitter/mcp/progress)
When an MCP client provides a progressToken in the tool call params, the server automatically creates a progress callback and injects it into the handler context as ctx.progress. Each call emits a notifications/progress notification.
const tool = defineTool({
name: "index-files",
description: "Index workspace files",
inputSchema: z.object({ path: z.string() }),
handler: async (input, ctx) => {
const files = await listFiles(input.path);
for (let i = 0; i < files.length; i++) {
await indexFile(files[i]);
ctx.progress?.({ type: "progress", current: i + 1, total: files.length });
}
ctx.progress?.({
type: "step",
name: "complete",
status: "complete",
duration_ms: 150,
});
return Result.ok({ indexed: files.length });
},
});Use optional chaining (ctx.progress?.()) so the handler works whether or not the client requested progress tracking. Without a progressToken, ctx.progress is undefined and no notifications are sent.
Event mapping to MCP notifications:
| StreamEvent type | MCP progress | MCP total | MCP message |
| ---------------- | -------------- | ----------- | ------------------------- |
| start | 0 | -- | [start] {command} |
| step | 0 | -- | [step] {name}: {status} |
| progress | current | total | message (if provided) |
The progress callback uses the same StreamEvent types from @outfitter/contracts/stream as the CLI NDJSON adapter, keeping handlers transport-agnostic.
Programmatic usage (for custom transport layers):
import { createMcpProgressCallback } from "@outfitter/mcp/progress";
const progress = createMcpProgressCallback("tok-123", (notification) =>
sdkServer.notification(notification)
);Core Tools
Pre-built tools for common MCP patterns. These are marked with deferLoading: false for immediate availability.
Docs Tool
Provides documentation, usage patterns, and examples.
import { defineDocsTool, createCoreTools } from "@outfitter/mcp";
const docsTool = defineDocsTool({
docs: {
overview: "Calculator server for arithmetic operations",
tools: [{ name: "add", summary: "Add two numbers" }],
examples: [{ input: { a: 2, b: 3 }, description: "Basic addition" }],
},
});
// Or use getDocs for dynamic content
const dynamicDocsTool = defineDocsTool({
getDocs: async (section) => {
return loadDocsFromFile(section);
},
});Config Tool
Read and modify server configuration.
import { defineConfigTool } from "@outfitter/mcp";
const configTool = defineConfigTool({
initial: { debug: false, maxRetries: 3 },
});
// With custom store
const persistedConfigTool = defineConfigTool({
store: {
get: async (key) => db.config.get(key),
set: async (key, value) => db.config.set(key, value),
list: async () => db.config.all(),
},
});Query Tool
Search and discovery with pagination.
import { defineQueryTool } from "@outfitter/mcp";
const queryTool = defineQueryTool({
handler: async (input, ctx) => {
const results = await searchIndex(input.q, {
limit: input.limit,
cursor: input.cursor,
filters: input.filters,
});
return Result.ok({
results: results.items,
nextCursor: results.nextCursor,
});
},
});Bundle All Core Tools
import { createCoreTools } from "@outfitter/mcp";
const coreTools = createCoreTools({
docs: { docs: myDocs },
config: { initial: myConfig },
query: { handler: myQueryHandler },
});
for (const tool of coreTools) {
server.registerTool(tool);
}Tool Annotations
Use TOOL_ANNOTATIONS presets to declare tool behavior hints without manually specifying all four booleans:
import { defineTool, TOOL_ANNOTATIONS } from "@outfitter/mcp";
// Use a preset directly
const listTool = defineTool({
name: "list-items",
description: "List all items",
inputSchema: z.object({}),
annotations: TOOL_ANNOTATIONS.readOnly,
handler: async (input, ctx) => {
/* ... */
},
});
// Spread and override for edge cases
const searchTool = defineTool({
name: "search",
description: "Search external APIs",
inputSchema: z.object({ q: z.string() }),
annotations: { ...TOOL_ANNOTATIONS.readOnly, openWorldHint: true },
handler: async (input, ctx) => {
/* ... */
},
});| Preset | readOnly | destructive | idempotent | openWorld |
| ----------------- | -------- | ----------- | ---------- | --------- |
| readOnly | true | false | true | false |
| write | false | false | false | false |
| writeIdempotent | false | false | true | false |
| destructive | false | true | true | false |
| openWorld | false | false | false | true |
For multi-action tools, use the most conservative union of hints. Per-action annotations are an MCP spec limitation.
adaptHandler
When your handler returns domain errors that extend Error but not OutfitterError, use adaptHandler instead of an unsafe cast:
import { adaptHandler, defineTool } from "@outfitter/mcp";
const tool = defineTool({
name: "my-tool",
inputSchema: z.object({ id: z.string() }),
handler: adaptHandler(myDomainHandler),
});Transport Helpers
wrapToolResult / wrapToolError
Format handler output as MCP tool responses. Useful when building custom transport layers or testing:
import { wrapToolResult, wrapToolError } from "@outfitter/mcp";
// Wrap a plain value as MCP tool content
const response = wrapToolResult({ count: 42 });
// { content: [{ type: "text", text: '{"count":42}' }] }
// Wrap an error with isError flag
const errorResponse = wrapToolError(new Error("not found"));
// { content: [{ type: "text", text: "not found" }], isError: true }connectStdio
Connect server to stdio transport for Claude Desktop integration.
import { createMcpServer, connectStdio } from "@outfitter/mcp";
const server = createMcpServer({ name: "my-server", version: "1.0.0" });
// ... register tools ...
await connectStdio(server);createSdkServer
Create the underlying @modelcontextprotocol/sdk server.
import { createSdkServer } from "@outfitter/mcp";
const { server: sdkServer, toolsList, callTool } = createSdkServer(mcpServer);Error Handling
Tools return Results with typed errors. The framework automatically translates OutfitterError categories to JSON-RPC error codes:
| Category | JSON-RPC Code | Description |
| ------------ | ------------- | ---------------- |
| validation | -32602 | Invalid params |
| not_found | -32601 | Method not found |
| permission | -32600 | Invalid request |
| internal | -32603 | Internal error |
const result = await server.invokeTool("get-user", { userId: "123" });
if (result.isErr()) {
// result.error is McpError with code and context
console.error(result.error.message, result.error.code);
}Schema Utilities
zodToJsonSchema
Convert Zod schemas to JSON Schema for MCP protocol.
import { zodToJsonSchema } from "@outfitter/mcp";
const schema = z.object({
name: z.string(),
age: z.number().optional(),
});
const jsonSchema = zodToJsonSchema(schema);
// { type: "object", properties: { name: { type: "string" }, ... } }Action Adapter
buildMcpTools
Build MCP tools from an action registry (for structured action-based servers).
import { buildMcpTools } from "@outfitter/mcp";
const tools = buildMcpTools({
actions: myActionRegistry,
prefix: "myapp",
});
for (const tool of tools) {
server.registerTool(tool);
}Claude Desktop Configuration
Add your MCP server to Claude Desktop:
{
"mcpServers": {
"my-server": {
"command": "bun",
"args": ["run", "/path/to/server.ts"]
}
}
}Config location:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/claude/claude_desktop_config.json
Upgrading
Run outfitter upgrade --guide for version-specific migration instructions, or check the migration docs for detailed upgrade steps.
Related Packages
- @outfitter/contracts — Result types and error taxonomy
- @outfitter/logging — Structured logging
- @outfitter/config — Configuration loading
