📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -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)
|
||||
+218
@@ -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");
|
||||
});
|
||||
});
|
||||
```
|
||||
+360
@@ -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/` |
|
||||
+230
@@ -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");
|
||||
});
|
||||
});
|
||||
```
|
||||
+302
@@ -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()
|
||||
```
|
||||
Reference in New Issue
Block a user