📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-05-29 08:33:53 +00:00
parent fdb52f1e96
commit 06e0d13d57
1615 changed files with 232858 additions and 0 deletions
@@ -0,0 +1,276 @@
---
name: stack-templates
version: 0.1.0
description: Templates for creating handlers, CLI commands, MCP tools, and daemon services following Outfitter Stack conventions. Use when scaffolding new components, creating handlers, adding commands, or when "create handler", "new command", "add tool", "scaffold", "template", or "daemon service" are mentioned.
context: fork
agent: stacker
allowed-tools: Read Write Edit Glob Grep
argument-hint: [component type]
---
# Stack Templates
Templates for creating @outfitter/* components.
## Component Types
| Type | Package | Template |
|------|---------|----------|
| Handler | `@outfitter/contracts` | [handler](#handler) |
| Handler Test | `@outfitter/testing` | [handler-test](#handler-test) |
| CLI Command | `@outfitter/cli` | [cli-command](#cli-command) |
| MCP Tool | `@outfitter/mcp` | [mcp-tool](#mcp-tool) |
| Daemon | `@outfitter/daemon` | [daemon-service](#daemon-service) |
## Handler
Transport-agnostic business logic returning `Result<T, E>`:
```typescript
import {
Result,
ValidationError,
NotFoundError,
createValidator,
type Handler,
} from "@outfitter/contracts";
import { z } from "zod";
// 1. Input schema
const InputSchema = z.object({
id: z.string().min(1),
});
type Input = z.infer<typeof InputSchema>;
// 2. Output type
interface Output {
id: string;
name: string;
}
// 3. Validator
const validateInput = createValidator(InputSchema);
// 4. Handler
export const myHandler: Handler<unknown, Output, ValidationError | NotFoundError> = async (
rawInput,
ctx
) => {
const inputResult = validateInput(rawInput);
if (inputResult.isErr()) return inputResult;
const input = inputResult.value;
ctx.logger.debug("Processing", { id: input.id });
const resource = await fetchResource(input.id);
if (!resource) {
return Result.err(new NotFoundError("resource", input.id));
}
return Result.ok(resource);
};
```
## Handler Test
Test handlers directly without transport layer:
```typescript
import { describe, test, expect } from "bun:test";
import { createContext } from "@outfitter/contracts";
import { myHandler } from "../handlers/my-handler.js";
describe("myHandler", () => {
test("returns success for valid input", async () => {
const ctx = createContext({});
const result = await myHandler({ id: "valid-id" }, ctx);
expect(result.isOk()).toBe(true);
expect(result.value).toMatchObject({ id: "valid-id" });
});
test("returns NotFoundError for missing resource", async () => {
const ctx = createContext({});
const result = await myHandler({ id: "missing" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("NotFoundError");
expect(result.error.resourceId).toBe("missing");
});
test("returns ValidationError for invalid input", async () => {
const ctx = createContext({});
const result = await myHandler({ id: "" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
});
});
```
## CLI Command
Commander.js command calling a handler:
```typescript
import { command, output, exitWithError } from "@outfitter/cli";
import { createContext } from "@outfitter/contracts";
import { myHandler } from "../handlers/my-handler.js";
export const myCommand = command("my-command")
.description("What this command does")
.argument("<id>", "Resource ID")
.option("-l, --limit <n>", "Limit results", parseInt)
.action(async ({ args, flags }) => {
const ctx = createContext({});
const result = await myHandler({ id: args.id, limit: flags.limit }, ctx);
if (result.isErr()) {
exitWithError(result.error);
}
await output(result.value);
})
.build();
```
Register in CLI:
```typescript
import { createCLI } from "@outfitter/cli";
import { myCommand } from "./commands/my-command.js";
const cli = createCLI({ name: "myapp", version: "1.0.0" });
cli.program.addCommand(myCommand);
cli.program.parse();
```
## MCP Tool
Use `defineTool()` for type-safe tool definitions with automatic schema inference:
```typescript
import { defineTool } from "@outfitter/mcp";
import { Result, ValidationError } from "@outfitter/contracts";
import { z } from "zod";
const InputSchema = z.object({
query: z.string().describe("Search query"),
limit: z.number().int().positive().default(10).describe("Max results"),
});
interface Output {
results: Array<{ id: string; title: string }>;
total: number;
}
export const myTool = defineTool({
name: "my_tool",
description: "Tool description for AI agent",
inputSchema: InputSchema,
handler: async (input): Promise<Result<Output, ValidationError>> => {
// input is automatically typed as z.infer<typeof InputSchema>
const results = await search(input.query, input.limit);
return Result.ok({ results, total: results.length });
},
});
```
Register in server:
```typescript
import { createMcpServer } from "@outfitter/mcp";
import { myTool } from "./tools/my-tool.js";
const server = createMcpServer({ name: "my-server", version: "0.1.0" });
server.registerTool(myTool);
server.start();
```
## Daemon Service
Background service with health checks and IPC:
```typescript
import {
createDaemon,
createIpcServer,
createHealthChecker,
getSocketPath,
getLockPath,
} from "@outfitter/daemon";
import { createLogger, createConsoleSink } from "@outfitter/logging";
import { Result } from "@outfitter/contracts";
const logger = createLogger({
name: "my-daemon",
level: "info",
sinks: [createConsoleSink()],
redaction: { enabled: true },
});
const daemon = createDaemon({
name: "my-daemon",
pidFile: getLockPath("my-daemon"),
logger,
shutdownTimeout: 10000,
});
const healthChecker = createHealthChecker([
{
name: "memory",
check: async () => {
const used = process.memoryUsage().heapUsed / 1024 / 1024;
return used < 500
? Result.ok(undefined)
: Result.err(new Error(`High memory: ${used.toFixed(2)}MB`));
},
},
]);
const ipcServer = createIpcServer(getSocketPath("my-daemon"));
ipcServer.onMessage(async (msg) => {
const message = msg as { type: string };
switch (message.type) {
case "status": return { status: "ok", uptime: process.uptime() };
case "health": return await healthChecker.check();
default: return { error: "Unknown command" };
}
});
daemon.onShutdown(async () => {
logger.info("Shutting down...");
await ipcServer.close();
});
async function main() {
const startResult = await daemon.start();
if (startResult.isErr()) {
logger.error("Failed to start", { error: startResult.error });
process.exit(1);
}
await ipcServer.listen();
logger.info("Started", { socket: getSocketPath("my-daemon") });
}
main();
```
## Best Practices
1. **Handler First** - Write handler before adapter (CLI/MCP/API)
2. **Validate Early** - Use `createValidator` at handler entry
3. **Type Errors** - List all error types in handler signature
4. **Context Propagation** - Pass context through all handler calls
5. **Test Handlers** - Test handlers directly without transport layer
## References
- [templates/handler.md](templates/handler.md)
- [templates/handler-test.md](templates/handler-test.md)
- [templates/cli-command.md](templates/cli-command.md)
- [templates/mcp-tool.md](templates/mcp-tool.md)
- [templates/daemon-service.md](templates/daemon-service.md)
@@ -0,0 +1,218 @@
# CLI Command Template
Commander.js command that wraps a handler.
## Template
```typescript
import { command, output, exitWithError } from "@outfitter/cli";
import { createContext } from "@outfitter/contracts";
import { myHandler } from "../handlers/my-handler.js";
export const myCommand = command("my-command")
// ========================================================================
// Metadata
// ========================================================================
.description("Brief description of what this command does")
// ========================================================================
// Arguments (positional)
// ========================================================================
.argument("<id>", "Required resource ID")
.argument("[name]", "Optional name")
// ========================================================================
// Options (flags)
// ========================================================================
.option("-l, --limit <n>", "Maximum number of results", parseInt)
.option("-v, --verbose", "Enable verbose output")
.option("-t, --tags <tags...>", "Filter by tags (multiple allowed)")
.option("--include-deleted", "Include deleted items")
.option("-o, --output <format>", "Output format", "table")
// ========================================================================
// Action
// ========================================================================
.action(async ({ args, flags }) => {
// Create context
const ctx = createContext({});
// Call handler
const result = await myHandler(
{
id: args.id,
name: args.name,
limit: flags.limit,
tags: flags.tags,
includeDeleted: flags.includeDeleted,
},
ctx
);
// Handle error
if (result.isErr()) {
exitWithError(result.error);
}
// Output success
await output(result.value);
})
// ========================================================================
// Build
// ========================================================================
.build();
```
## Registration
```typescript
import { createCLI } from "@outfitter/cli";
import { myCommand } from "./commands/my-command.js";
import { otherCommand } from "./commands/other-command.js";
const cli = createCLI({
name: "myapp",
version: "1.0.0",
description: "My CLI application",
});
// Register commands
cli.program.addCommand(myCommand);
cli.program.addCommand(otherCommand);
// Parse and execute
cli.program.parse();
```
## Checklist
- [ ] Description is clear and concise
- [ ] Arguments use `<required>` and `[optional]` syntax
- [ ] Options have short and long forms where appropriate
- [ ] Numeric options use `parseInt` or `parseFloat`
- [ ] Handler is called with structured input
- [ ] Errors use `exitWithError()` for correct exit codes
- [ ] Success uses `await output()` for format detection
## Patterns
### Pagination Support
```typescript
import { loadCursor, saveCursor, clearCursor } from "@outfitter/cli";
export const listCommand = command("list")
.option("-n, --next", "Continue from previous position")
.option("--reset", "Reset pagination cursor")
.option("-l, --limit <n>", "Results per page", parseInt, 20)
.action(async ({ flags }) => {
const paginationOpts = { command: "list", toolName: "myapp" };
if (flags.reset) {
clearCursor(paginationOpts);
console.log("Cursor reset");
return;
}
const cursor = flags.next ? loadCursor(paginationOpts)?.cursor : undefined;
const ctx = createContext({});
const result = await listHandler({ cursor, limit: flags.limit }, ctx);
if (result.isErr()) {
exitWithError(result.error);
}
await output(result.value.items);
if (result.value.nextCursor) {
saveCursor(result.value.nextCursor, paginationOpts);
console.log("\nUse --next for more results");
}
})
.build();
```
### Subcommands
```typescript
import { Command } from "commander";
const userCommand = new Command("user")
.description("User management commands");
userCommand.addCommand(
command("create")
.argument("<email>", "User email")
.action(async ({ args }) => { /* ... */ })
.build()
);
userCommand.addCommand(
command("delete")
.argument("<id>", "User ID")
.option("--force", "Skip confirmation")
.action(async ({ args, flags }) => { /* ... */ })
.build()
);
cli.program.addCommand(userCommand);
```
### Interactive Prompts
```typescript
import { confirm, text, select } from "@clack/prompts";
export const deleteCommand = command("delete")
.argument("<id>", "Resource ID")
.option("--force", "Skip confirmation")
.action(async ({ args, flags }) => {
if (!flags.force) {
const confirmed = await confirm({
message: `Delete resource ${args.id}?`,
});
if (!confirmed) {
console.log("Cancelled");
return;
}
}
// Proceed with deletion
})
.build();
```
## Test Template
```typescript
import { describe, test, expect } from "bun:test";
import { createCliHarness } from "@outfitter/testing";
import { myCommand } from "../commands/my-command.js";
const harness = createCliHarness(myCommand);
describe("my-command", () => {
test("outputs JSON with --json flag", async () => {
const result = await harness.run(["test-id", "--json"]);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ id: "test-id" });
});
test("exits with error for missing resource", async () => {
const result = await harness.run(["missing-id"]);
expect(result.exitCode).toBe(2); // not_found
expect(result.stderr).toContain("not found");
});
test("validates required arguments", async () => {
const result = await harness.run([]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("required");
});
});
```
@@ -0,0 +1,360 @@
# Daemon Service Template
Background service with lifecycle management, IPC, and health checks.
## Template
```typescript
import {
createDaemon,
createIpcServer,
createHealthChecker,
getSocketPath,
getLockPath,
getLogPath,
} from "@outfitter/daemon";
import { createLogger, createConsoleSink, createFileSink } from "@outfitter/logging";
import { Result } from "@outfitter/contracts";
// ============================================================================
// Configuration
// ============================================================================
const DAEMON_NAME = "my-daemon";
const SHUTDOWN_TIMEOUT = 10000; // 10 seconds
const HEALTH_CHECK_INTERVAL = 30000; // 30 seconds
// ============================================================================
// Logger Setup
// ============================================================================
const logger = createLogger({
name: DAEMON_NAME,
level: process.env.LOG_LEVEL || "info",
sinks: [
createConsoleSink({ colorize: true }),
createFileSink({
path: `${getLogPath(DAEMON_NAME)}/daemon.log`,
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
}),
],
redaction: { enabled: true },
});
// ============================================================================
// Daemon Setup
// ============================================================================
const daemon = createDaemon({
name: DAEMON_NAME,
pidFile: getLockPath(DAEMON_NAME),
logger,
shutdownTimeout: SHUTDOWN_TIMEOUT,
});
// ============================================================================
// Health Checks
// ============================================================================
const healthChecker = createHealthChecker([
{
name: "memory",
check: async () => {
const used = process.memoryUsage().heapUsed / 1024 / 1024;
const threshold = 500; // MB
return used < threshold
? Result.ok(undefined)
: Result.err(new Error(`High memory usage: ${used.toFixed(2)}MB`));
},
},
{
name: "uptime",
check: async () => {
// Always healthy, just reports uptime
return Result.ok(undefined);
},
},
// Add more checks as needed:
// - Database connectivity
// - External API availability
// - Disk space
// - Queue depth
]);
// ============================================================================
// IPC Server
// ============================================================================
const ipcServer = createIpcServer(getSocketPath(DAEMON_NAME));
interface IpcMessage {
type: string;
payload?: unknown;
}
interface StatusResponse {
status: "ok" | "degraded" | "error";
uptime: number;
version: string;
pid: number;
}
interface HealthResponse {
healthy: boolean;
checks: Record<string, { ok: boolean; error?: string }>;
}
ipcServer.onMessage(async (msg): Promise<unknown> => {
const message = msg as IpcMessage;
switch (message.type) {
case "status":
return {
status: "ok",
uptime: process.uptime(),
version: "1.0.0",
pid: process.pid,
} satisfies StatusResponse;
case "health": {
const result = await healthChecker.check();
return {
healthy: result.isOk(),
checks: result.isOk() ? result.value : result.error,
} satisfies HealthResponse;
}
case "reload":
logger.info("Reloading configuration");
await reloadConfiguration();
return { success: true };
case "shutdown":
logger.info("Shutdown requested via IPC");
process.kill(process.pid, "SIGTERM");
return { success: true };
default:
return { error: `Unknown command: ${message.type}` };
}
});
// ============================================================================
// Lifecycle Hooks
// ============================================================================
daemon.onBeforeStart(async () => {
logger.info("Preparing to start daemon");
await initializeResources();
});
daemon.onAfterStart(async () => {
logger.info("Daemon started successfully", {
pid: process.pid,
socket: getSocketPath(DAEMON_NAME),
});
// Start periodic health checks
setInterval(async () => {
const result = await healthChecker.check();
if (result.isErr()) {
logger.warn("Health check failed", { checks: result.error });
}
}, HEALTH_CHECK_INTERVAL);
});
daemon.onShutdown(async () => {
logger.info("Shutting down daemon");
// Close IPC server
await ipcServer.close();
// Cleanup resources
await cleanupResources();
logger.info("Daemon shutdown complete");
});
// ============================================================================
// Main Entry Point
// ============================================================================
async function main() {
// Start daemon (handles PID file, signals)
const startResult = await daemon.start();
if (startResult.isErr()) {
logger.error("Failed to start daemon", { error: startResult.error });
process.exit(1);
}
// Start IPC server
await ipcServer.listen();
logger.info("IPC server listening", { socket: getSocketPath(DAEMON_NAME) });
// Start main work loop
await runMainLoop();
}
// ============================================================================
// Application Logic
// ============================================================================
async function initializeResources(): Promise<void> {
// Initialize database connections, caches, etc.
}
async function cleanupResources(): Promise<void> {
// Close connections, flush buffers, etc.
}
async function reloadConfiguration(): Promise<void> {
// Reload configuration without restart
}
async function runMainLoop(): Promise<void> {
// Main daemon work loop
while (!daemon.isShuttingDown) {
// Do work
await processNextItem();
await Bun.sleep(1000);
}
}
async function processNextItem(): Promise<void> {
// Process one unit of work
}
// ============================================================================
// Start
// ============================================================================
main().catch((error) => {
logger.fatal("Unhandled error", { error });
process.exit(1);
});
```
## CLI Commands
```typescript
import { command } from "@outfitter/cli";
import {
createIpcClient,
getSocketPath,
isDaemonRunning,
} from "@outfitter/daemon";
import { spawn } from "child_process";
const DAEMON_NAME = "my-daemon";
// Start command
export const startCommand = command("start")
.description("Start the daemon")
.option("-d, --detach", "Run in background")
.action(async ({ flags }) => {
if (await isDaemonRunning(DAEMON_NAME)) {
console.log("Daemon is already running");
return;
}
if (flags.detach) {
spawn("bun", ["run", "src/daemon.ts"], {
detached: true,
stdio: "ignore",
}).unref();
console.log("Daemon started in background");
} else {
// Import and run directly
await import("./daemon.js");
}
})
.build();
// Stop command
export const stopCommand = command("stop")
.description("Stop the daemon")
.action(async () => {
const client = createIpcClient(getSocketPath(DAEMON_NAME));
try {
await client.connect();
await client.send({ type: "shutdown" });
console.log("Daemon stopping");
} catch {
console.log("Daemon is not running");
} finally {
client.close();
}
})
.build();
// Status command
export const statusCommand = command("status")
.description("Check daemon status")
.action(async () => {
const client = createIpcClient(getSocketPath(DAEMON_NAME));
try {
await client.connect();
const status = await client.send<{
status: string;
uptime: number;
pid: number;
}>({ type: "status" });
console.log(`Status: ${status.status}`);
console.log(`PID: ${status.pid}`);
console.log(`Uptime: ${Math.floor(status.uptime)}s`);
} catch {
console.log("Daemon is not running");
} finally {
client.close();
}
})
.build();
// Health command
export const healthCommand = command("health")
.description("Check daemon health")
.action(async () => {
const client = createIpcClient(getSocketPath(DAEMON_NAME));
try {
await client.connect();
const health = await client.send<{
healthy: boolean;
checks: Record<string, { ok: boolean; error?: string }>;
}>({ type: "health" });
console.log(`Healthy: ${health.healthy}`);
for (const [name, check] of Object.entries(health.checks)) {
const status = check.ok ? "✓" : "✗";
const message = check.error ? ` (${check.error})` : "";
console.log(` ${status} ${name}${message}`);
}
} catch {
console.log("Daemon is not running");
} finally {
client.close();
}
})
.build();
```
## Checklist
- [ ] Graceful shutdown with `onShutdown` hook
- [ ] PID file in XDG state directory
- [ ] IPC socket for control commands
- [ ] Health checks for critical dependencies
- [ ] Structured logging with redaction
- [ ] CLI commands for start/stop/status/health
## XDG Paths
| Function | Path | Example |
|----------|------|---------|
| `getLockPath(name)` | `~/.local/state/{name}/{name}.pid` | `~/.local/state/my-daemon/my-daemon.pid` |
| `getSocketPath(name)` | `~/.local/state/{name}/{name}.sock` | `~/.local/state/my-daemon/my-daemon.sock` |
| `getLogPath(name)` | `~/.local/state/{name}/logs/` | `~/.local/state/my-daemon/logs/` |
@@ -0,0 +1,230 @@
# Handler Test Template
Test handlers directly without transport layer using Bun test runner.
## Template
```typescript
import { describe, test, expect, beforeEach } from "bun:test";
import { createContext, type HandlerContext } from "@outfitter/contracts";
import { myHandler } from "../handlers/my-handler.js";
describe("myHandler", () => {
let ctx: HandlerContext;
beforeEach(() => {
ctx = createContext({});
});
// ============================================================================
// Success Cases
// ============================================================================
test("returns success for valid input", async () => {
const result = await myHandler({ id: "valid-id" }, ctx);
expect(result.isOk()).toBe(true);
expect(result.value).toMatchObject({
id: "valid-id",
// Add expected properties
});
});
test("returns success with optional parameters", async () => {
const result = await myHandler(
{ id: "valid-id", includeDeleted: true },
ctx
);
expect(result.isOk()).toBe(true);
// Assert on optional behavior
});
// ============================================================================
// Error Cases
// ============================================================================
test("returns NotFoundError for missing resource", async () => {
const result = await myHandler({ id: "missing" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("NotFoundError");
expect(result.error.resourceType).toBe("resource");
expect(result.error.resourceId).toBe("missing");
});
test("returns ValidationError for empty id", async () => {
const result = await myHandler({ id: "" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
});
test("returns ValidationError for missing required field", async () => {
const result = await myHandler({} as any, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
});
// ============================================================================
// Edge Cases
// ============================================================================
test("handles special characters in id", async () => {
const result = await myHandler({ id: "user-123/test" }, ctx);
// Assert expected behavior
});
test("respects cancellation signal", async () => {
const controller = new AbortController();
const ctxWithSignal = createContext({ signal: controller.signal });
controller.abort();
const result = await myHandler({ id: "valid-id" }, ctxWithSignal);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("CancelledError");
});
});
```
## Checklist
- [ ] Test success cases with valid input
- [ ] Test all error types in handler signature
- [ ] Test validation errors for invalid/missing input
- [ ] Test edge cases (special characters, empty arrays, etc.)
- [ ] Test cancellation if handler supports it
- [ ] Use `createContext({})` for test context
- [ ] Check `result.isOk()` / `result.isErr()` before accessing value/error
- [ ] Use `_tag` for error type discrimination
## Result Assertions
### Success Assertions
```typescript
// Check success
expect(result.isOk()).toBe(true);
// Access value (type-safe after isOk check)
expect(result.value.id).toBe("expected-id");
// Match object structure
expect(result.value).toMatchObject({
id: "expected-id",
name: expect.any(String),
});
// Array assertions
expect(result.value.items).toHaveLength(3);
expect(result.value.items[0]).toMatchObject({ type: "expected" });
```
### Error Assertions
```typescript
// Check error
expect(result.isErr()).toBe(true);
// Check error type
expect(result.error._tag).toBe("NotFoundError");
// Check error category
expect(result.error.category).toBe("not_found");
// Check error properties
expect(result.error.resourceType).toBe("user");
expect(result.error.resourceId).toBe("123");
// Check error message
expect(result.error.message).toContain("not found");
// Check error details
expect(result.error.details).toMatchObject({
field: "email",
});
```
## Testing with Mock Logger
```typescript
import { createMockLogger } from "@outfitter/testing";
test("logs debug messages", async () => {
const mockLogger = createMockLogger();
const ctx = createContext({ logger: mockLogger });
await myHandler({ id: "123" }, ctx);
expect(mockLogger.calls.debug).toContainEqual([
"Processing",
{ id: "123" },
]);
});
```
## Testing with Fixtures
```typescript
import { createFixture } from "@outfitter/testing";
interface User {
id: string;
name: string;
email: string;
settings: { theme: string };
}
const createUser = createFixture<User>({
id: "user-1",
name: "Test User",
email: "test@example.com",
settings: { theme: "light" },
});
test("processes user with custom settings", async () => {
const user = createUser({ settings: { theme: "dark" } });
// user.name is still "Test User" (deep merge)
// user.settings.theme is "dark"
});
```
## Testing with Temporary Directories
```typescript
import { withTempDir } from "@outfitter/testing";
test("writes config file", async () => {
await withTempDir(async (dir) => {
const ctx = createContext({ workspaceRoot: dir });
const result = await writeConfigHandler({ data: { key: "value" } }, ctx);
expect(result.isOk()).toBe(true);
const content = await Bun.file(`${dir}/config.json`).json();
expect(content).toEqual({ key: "value" });
});
});
```
## Running Tests
```bash
# All tests
bun test
# Single file
bun test src/__tests__/my-handler.test.ts
# Watch mode
bun test --watch
# With coverage
bun test --coverage
# Filter by name
bun test --filter "returns success"
```
@@ -0,0 +1,146 @@
# Handler Template
Transport-agnostic business logic returning `Result<T, E>`.
## Template
```typescript
import {
Result,
ValidationError,
NotFoundError,
createValidator,
type Handler,
type HandlerContext,
} from "@outfitter/contracts";
import { z } from "zod";
// ============================================================================
// Input Schema
// ============================================================================
const InputSchema = z.object({
// Required fields
id: z.string().min(1, "ID is required"),
// Optional fields with defaults
includeDeleted: z.boolean().default(false),
// Optional fields without defaults
limit: z.number().int().positive().optional(),
});
type Input = z.infer<typeof InputSchema>;
// ============================================================================
// Output Type
// ============================================================================
interface Output {
id: string;
name: string;
createdAt: Date;
}
// ============================================================================
// Error Types
// ============================================================================
type HandlerErrors = ValidationError | NotFoundError;
// ============================================================================
// Validator
// ============================================================================
const validateInput = createValidator(InputSchema);
// ============================================================================
// Handler Implementation
// ============================================================================
export const myHandler: Handler<unknown, Output, HandlerErrors> = async (
rawInput,
ctx
) => {
// 1. Validate input
const inputResult = validateInput(rawInput);
if (inputResult.isErr()) return inputResult;
const input = inputResult.value;
// 2. Log entry
ctx.logger.debug("Processing request", {
id: input.id,
requestId: ctx.requestId,
});
// 3. Business logic
const resource = await fetchResource(input.id, {
includeDeleted: input.includeDeleted,
});
if (!resource) {
return Result.err(new NotFoundError("resource", input.id));
}
// 4. Log success
ctx.logger.debug("Request completed", { id: input.id });
// 5. Return result
return Result.ok(resource);
};
// ============================================================================
// Helper Functions (private)
// ============================================================================
async function fetchResource(
id: string,
options: { includeDeleted: boolean }
): Promise<Output | null> {
// Implementation
return null;
}
```
## Checklist
- [ ] Input validated with `createValidator`
- [ ] Handler signature includes all error types
- [ ] Uses `ctx.logger` for logging
- [ ] Returns `Result.ok()` or `Result.err()`
- [ ] No thrown exceptions
- [ ] Context passed to nested handlers
## Test Template
```typescript
import { describe, test, expect } from "bun:test";
import { createContext } from "@outfitter/contracts";
import { myHandler } from "../handlers/my-handler.js";
describe("myHandler", () => {
const ctx = createContext({});
test("returns success for valid input", async () => {
const result = await myHandler({ id: "valid-id" }, ctx);
expect(result.isOk()).toBe(true);
expect(result.value).toMatchObject({ id: "valid-id" });
});
test("returns NotFoundError for missing resource", async () => {
const result = await myHandler({ id: "missing" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("NotFoundError");
expect(result.error.resourceId).toBe("missing");
});
test("returns ValidationError for invalid input", async () => {
const result = await myHandler({ id: "" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
});
});
```
@@ -0,0 +1,302 @@
# MCP Tool Template
Zod-schema-based tool for MCP servers returning `Result<T, E>`.
## Template
```typescript
import { Result, ValidationError, NotFoundError } from "@outfitter/contracts";
import { z } from "zod";
// ============================================================================
// Input Schema
// ============================================================================
const InputSchema = z.object({
// Always use .describe() for AI understanding
query: z.string().min(1).describe("The search term to look for"),
// Provide defaults where sensible
limit: z.number().int().positive().default(10)
.describe("Maximum number of results to return"),
// Use enums for fixed choices
sortBy: z.enum(["name", "date", "relevance"]).default("relevance")
.describe("Field to sort results by"),
// Mark optional fields explicitly
tags: z.array(z.string()).optional()
.describe("Filter results by these tags"),
// Boolean options
includeArchived: z.boolean().default(false)
.describe("Whether to include archived items"),
});
// ============================================================================
// Output Type
// ============================================================================
interface SearchResult {
id: string;
title: string;
score: number;
}
interface Output {
results: SearchResult[];
total: number;
hasMore: boolean;
}
// ============================================================================
// Error Types
// ============================================================================
type ToolErrors = ValidationError | NotFoundError;
// ============================================================================
// Tool Definition
// ============================================================================
export const searchTool = {
name: "search_items",
description: `Search for items in the database.
Use this tool when the user wants to:
- Find items by keyword
- Search for specific content
- List items matching criteria
Returns matching items with relevance scores.`,
inputSchema: InputSchema,
handler: async (
input: z.infer<typeof InputSchema>
): Promise<Result<Output, ToolErrors>> => {
// Business logic
const results = await performSearch({
query: input.query,
limit: input.limit,
sortBy: input.sortBy,
tags: input.tags,
includeArchived: input.includeArchived,
});
return Result.ok({
results,
total: results.length,
hasMore: results.length === input.limit,
});
},
};
// ============================================================================
// Helper Functions
// ============================================================================
async function performSearch(options: {
query: string;
limit: number;
sortBy: string;
tags?: string[];
includeArchived: boolean;
}): Promise<SearchResult[]> {
// Implementation
return [];
}
```
## Registration
```typescript
import { createMcpServer } from "@outfitter/mcp";
import { searchTool } from "./tools/search.js";
import { createTool } from "./tools/create.js";
const server = createMcpServer({
name: "my-server",
version: "0.1.0",
description: "MCP server for item management",
});
// Register tools
server.registerTool(searchTool);
server.registerTool(createTool);
// Start server
server.start();
```
## Checklist
- [ ] Every schema field has `.describe()` for AI understanding
- [ ] Sensible defaults with `.default()` where appropriate
- [ ] Description explains WHEN to use the tool
- [ ] Returns `Result`, not raw values
- [ ] Error types from taxonomy
## Patterns
### Tool with Context
```typescript
export const myTool = {
name: "my_tool",
description: "Tool with context access",
inputSchema: InputSchema,
handler: async (input, ctx) => {
// Log invocation
ctx.logger.debug("Tool invoked", { input });
// Call handler
const result = await myHandler(input, ctx);
// Log outcome
if (result.isErr()) {
ctx.logger.error("Tool failed", { error: result.error });
}
return result;
},
};
```
### CRUD Tool Set
```typescript
// List
export const listItemsTool = {
name: "list_items",
description: "List all items. Use when user wants to see available items.",
inputSchema: z.object({
limit: z.number().default(20).describe("Max items to return"),
offset: z.number().default(0).describe("Number of items to skip"),
}),
handler: async (input) => { /* ... */ },
};
// Get
export const getItemTool = {
name: "get_item",
description: "Get a specific item by ID. Use when user asks about a specific item.",
inputSchema: z.object({
id: z.string().describe("The item ID to retrieve"),
}),
handler: async (input) => { /* ... */ },
};
// Create
export const createItemTool = {
name: "create_item",
description: "Create a new item. Use when user wants to add something new.",
inputSchema: z.object({
name: z.string().describe("Name for the new item"),
description: z.string().optional().describe("Optional description"),
}),
handler: async (input) => { /* ... */ },
};
// Update
export const updateItemTool = {
name: "update_item",
description: "Update an existing item. Use when user wants to modify an item.",
inputSchema: z.object({
id: z.string().describe("The item ID to update"),
name: z.string().optional().describe("New name"),
description: z.string().optional().describe("New description"),
}),
handler: async (input) => { /* ... */ },
};
// Delete
export const deleteItemTool = {
name: "delete_item",
description: "Delete an item. Use when user wants to remove an item.",
inputSchema: z.object({
id: z.string().describe("The item ID to delete"),
}),
handler: async (input) => { /* ... */ },
};
```
### Deferred Loading
```typescript
server.registerDeferredTool({
name: "heavy_analysis",
description: "Perform heavy analysis (loads on demand)",
load: async () => {
// Only loaded when tool is first called
const { analysisTool } = await import("./tools/analysis.js");
return analysisTool;
},
});
```
## Test Template
```typescript
import { describe, test, expect } from "bun:test";
import { createMcpHarness } from "@outfitter/testing";
import { searchTool } from "../tools/search.js";
const harness = createMcpHarness(searchTool);
describe("search_items", () => {
test("returns results for valid query", async () => {
const result = await harness.invoke({
query: "test",
limit: 5,
});
expect(result.isOk()).toBe(true);
expect(result.value.results).toBeInstanceOf(Array);
});
test("uses default limit", async () => {
const result = await harness.invoke({ query: "test" });
expect(result.isOk()).toBe(true);
// Default limit is 10
});
test("returns ValidationError for empty query", async () => {
const result = await harness.invoke({ query: "" });
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
});
});
```
## Schema Best Practices
```typescript
// DO: Use descriptive field names
query: z.string().describe("Search query")
// DON'T: Cryptic names
q: z.string()
// DO: Provide sensible defaults
limit: z.number().default(10)
// DON'T: Require every field
limit: z.number()
// DO: Use enums for fixed options
format: z.enum(["json", "csv", "xml"])
// DON'T: Accept any string
format: z.string()
// DO: Validate ranges
page: z.number().int().min(1).max(100)
// DON'T: Accept any number
page: z.number()
```