improved a lot of things
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { TOOLS } from '../tools.js';
|
||||
|
||||
// ── Claude Code adapter ───────────────────────────────────────────────────────
|
||||
// Generates two artifacts for Claude Code MCP integration:
|
||||
// 1. CLAUDE.md section — tool reference injected into the project memory
|
||||
// 2. MCP server declaration — JSON block for .claude/settings.json
|
||||
//
|
||||
// Claude Code discovers MCP servers from .claude/settings.json and injects
|
||||
// CLAUDE.md into every session's system context automatically.
|
||||
|
||||
export interface ClaudeCodeAdapterOptions {
|
||||
/** MCP WebSocket endpoint URL */
|
||||
mcpServerUrl: string;
|
||||
/** Signed workspace token (from issueWorkspaceToken) */
|
||||
workspaceToken: string;
|
||||
workspaceName: string;
|
||||
/** Optional project name shown in CLAUDE.md header */
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeAdapterOutput {
|
||||
claudeMdSection: string;
|
||||
mcpServerDeclaration: ClaudeCodeMcpServer;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeMcpServer {
|
||||
name: string;
|
||||
type: 'sse' | 'stdio';
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export function generateClaudeCodeConfig(opts: ClaudeCodeAdapterOptions): ClaudeCodeAdapterOutput {
|
||||
const projectLabel = opts.projectName ?? opts.workspaceName;
|
||||
const toolLines = TOOLS.map(t => `- \`${t.name}\`: ${t.description}`).join('\n');
|
||||
|
||||
const claudeMdSection = `## Origin Design Agent Bridge — ${projectLabel}
|
||||
|
||||
This project is connected to an Origin MCP server that exposes design-to-code tools.
|
||||
The server is pre-configured in \`.claude/settings.json\`.
|
||||
|
||||
### Available MCP Tools
|
||||
${toolLines}
|
||||
|
||||
### Implementation Workflow
|
||||
1. \`get_pending_diffs\` → list EXPORTED IntentDiffs that need code changes
|
||||
2. \`get_artboard_context\` → load component tree, screenshots, and design language
|
||||
3. Implement the required changes in the codebase
|
||||
4. \`ask_design_agent\` → clarify design intent when the diff is ambiguous
|
||||
5. \`update_diff_status\` → mark IMPLEMENTED or BLOCKED with an explanation
|
||||
|
||||
### Design Language Validation
|
||||
Run \`get_design_language\` at session start to cache the team's active Design Language File.
|
||||
All token references (colors, typography, spacing) must match the DLF.
|
||||
|
||||
### Rate Limit
|
||||
100 diff exports/hour per workspace. If you hit the limit, wait before retrying.
|
||||
`;
|
||||
|
||||
const mcpServerDeclaration: ClaudeCodeMcpServer = {
|
||||
name: 'origin',
|
||||
type: 'sse',
|
||||
url: opts.mcpServerUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.workspaceToken}`,
|
||||
},
|
||||
};
|
||||
|
||||
return { claudeMdSection, mcpServerDeclaration };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { TOOLS } from '../tools.js';
|
||||
|
||||
// ── Cursor adapter ────────────────────────────────────────────────────────────
|
||||
// Generates the two files Cursor reads to discover MCP tools:
|
||||
// 1. .cursorrules — natural-language context injected into every prompt
|
||||
// 2. cursor_settings.json — MCP server registration (added to .cursor/settings)
|
||||
//
|
||||
// Both are returned as strings; the caller writes them to the workspace root.
|
||||
|
||||
export interface CursorAdapterOptions {
|
||||
/** MCP WebSocket endpoint URL */
|
||||
mcpServerUrl: string;
|
||||
/** Signed workspace token (from issueWorkspaceToken) */
|
||||
workspaceToken: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
export interface CursorAdapterOutput {
|
||||
cursorrules: string;
|
||||
cursorSettings: CursorSettings;
|
||||
}
|
||||
|
||||
interface McpServerEntry {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CursorSettings {
|
||||
mcpServers: Record<string, McpServerEntry>;
|
||||
}
|
||||
|
||||
export function generateCursorConfig(opts: CursorAdapterOptions): CursorAdapterOutput {
|
||||
const toolDescriptions = TOOLS.map(t => `- **${t.name}**: ${t.description}`).join('\n');
|
||||
|
||||
const cursorrules = `# Origin — Design-to-Code Agent Bridge
|
||||
# Workspace: ${opts.workspaceName}
|
||||
#
|
||||
# You have access to the following Origin MCP tools via the connected MCP server.
|
||||
# Use them to fetch design diffs, query artboard context, and report implementation status.
|
||||
#
|
||||
## Available Tools
|
||||
${toolDescriptions}
|
||||
|
||||
## Workflow
|
||||
1. Call \`get_pending_diffs\` to retrieve EXPORTED IntentDiffs awaiting implementation.
|
||||
2. Call \`get_artboard_context\` to load component tree, design language, and screenshots.
|
||||
3. Implement the diff. Use \`ask_design_agent\` if design intent is unclear.
|
||||
4. Call \`update_diff_status\` with IMPLEMENTED or BLOCKED when done.
|
||||
|
||||
## Design Language
|
||||
Always validate tokens/colors/spacing against the workspace Design Language File.
|
||||
Call \`get_design_language\` once per session to cache the active DLF locally.
|
||||
|
||||
## Rate Limits
|
||||
100 diff exports per hour per workspace. The server returns HTTP 429 when exceeded.
|
||||
`;
|
||||
|
||||
const cursorSettings: CursorSettings = {
|
||||
mcpServers: {
|
||||
origin: {
|
||||
url: opts.mcpServerUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.workspaceToken}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { cursorrules, cursorSettings };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
// ── Workspace token ───────────────────────────────────────────────────────────
|
||||
// Format: base64(workspaceId:agentType:timestamp:hmac)
|
||||
// HMAC-SHA256 signed with AGENT_BRIDGE_SECRET (server-side env var).
|
||||
// Tokens expire after 30 days.
|
||||
|
||||
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
export type AgentType = 'CURSOR' | 'CLAUDE_CODE' | 'GENERIC';
|
||||
|
||||
export interface WorkspaceToken {
|
||||
workspaceId: string;
|
||||
agentType: AgentType;
|
||||
issuedAt: number;
|
||||
}
|
||||
|
||||
function getSecret(): string {
|
||||
const secret = process.env.AGENT_BRIDGE_SECRET;
|
||||
if (!secret) throw new Error('AGENT_BRIDGE_SECRET is not set');
|
||||
return secret;
|
||||
}
|
||||
|
||||
function sign(payload: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(payload).digest('hex');
|
||||
}
|
||||
|
||||
export function issueWorkspaceToken(workspaceId: string, agentType: AgentType): string {
|
||||
const issuedAt = Date.now();
|
||||
const payload = `${workspaceId}:${agentType}:${issuedAt}`;
|
||||
const hmac = sign(payload, getSecret());
|
||||
return Buffer.from(`${payload}:${hmac}`).toString('base64url');
|
||||
}
|
||||
|
||||
export function verifyWorkspaceToken(token: string): WorkspaceToken | null {
|
||||
// Decode base64url — this is the only step that can throw for reasons outside
|
||||
// our control (malformed input). All other failures are deterministic logic.
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(token, 'base64url').toString('utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = decoded.split(':');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
const [workspaceId, agentType, issuedAtStr, providedHmac] = parts;
|
||||
if (!workspaceId || !agentType || !issuedAtStr || !providedHmac) return null;
|
||||
|
||||
const issuedAt = parseInt(issuedAtStr, 10);
|
||||
if (isNaN(issuedAt) || Date.now() - issuedAt > TOKEN_TTL_MS) return null;
|
||||
|
||||
const payload = `${workspaceId}:${agentType}:${issuedAtStr}`;
|
||||
// getSecret() intentionally throws if AGENT_BRIDGE_SECRET is unset —
|
||||
// that's a server misconfiguration, not an invalid token.
|
||||
const expectedHmac = sign(payload, getSecret());
|
||||
|
||||
const expected = Buffer.from(expectedHmac, 'hex');
|
||||
const provided = Buffer.from(providedHmac, 'hex');
|
||||
if (expected.length !== provided.length) return null;
|
||||
if (!timingSafeEqual(expected, provided)) return null;
|
||||
|
||||
const validAgentTypes: AgentType[] = ['CURSOR', 'CLAUDE_CODE', 'GENERIC'];
|
||||
if (!validAgentTypes.includes(agentType as AgentType)) return null;
|
||||
|
||||
return { workspaceId, agentType: agentType as AgentType, issuedAt };
|
||||
}
|
||||
@@ -1 +1,17 @@
|
||||
export {};
|
||||
export type { JsonRpcRequest, JsonRpcSuccess, JsonRpcError, JsonRpcResponse, AuthRequest, AuthAck, ToolResult } from './protocol.js';
|
||||
export { MCP_ERROR, textResult, jsonResult } from './protocol.js';
|
||||
|
||||
export type { WorkspaceToken, AgentType } from './auth.js';
|
||||
export { issueWorkspaceToken, verifyWorkspaceToken } from './auth.js';
|
||||
|
||||
export type { RateLimitResult } from './rate-limiter.js';
|
||||
export { checkRateLimit, getRateLimitStatus } from './rate-limiter.js';
|
||||
|
||||
export type { McpTool, ToolContext } from './tools.js';
|
||||
export { TOOLS, TOOL_MAP, getToolList } from './tools.js';
|
||||
|
||||
export type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js';
|
||||
export { generateCursorConfig } from './adapters/cursor.js';
|
||||
|
||||
export type { ClaudeCodeAdapterOptions, ClaudeCodeAdapterOutput, ClaudeCodeMcpServer } from './adapters/claude-code.js';
|
||||
export { generateClaudeCodeConfig } from './adapters/claude-code.js';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// ── MCP / JSON-RPC 2.0 protocol types ────────────────────────────────────────
|
||||
// The Agent Bridge implements MCP over JSON-RPC 2.0, transported via WebSocket
|
||||
// (long-running) or HTTP POST (polling-based agents).
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcSuccess<T = unknown> {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
result: T;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number | null;
|
||||
error: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type JsonRpcResponse<T = unknown> = JsonRpcSuccess<T> | JsonRpcError;
|
||||
|
||||
// ── MCP error codes ───────────────────────────────────────────────────────────
|
||||
export const MCP_ERROR = {
|
||||
PARSE_ERROR: -32700,
|
||||
INVALID_REQUEST: -32600,
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
INVALID_PARAMS: -32602,
|
||||
INTERNAL_ERROR: -32603,
|
||||
// Application-level codes
|
||||
UNAUTHORIZED: -32001,
|
||||
RATE_LIMITED: -32002,
|
||||
NOT_FOUND: -32003,
|
||||
} as const;
|
||||
|
||||
// ── Origmain-specific envelope ────────────────────────────────────────────────
|
||||
// Every connection must present a signed workspace token in the first message.
|
||||
|
||||
export interface AuthRequest {
|
||||
type: 'auth';
|
||||
workspaceToken: string;
|
||||
}
|
||||
|
||||
export interface AuthAck {
|
||||
type: 'auth_ack';
|
||||
workspaceId: string;
|
||||
agentType: 'CURSOR' | 'CLAUDE_CODE' | 'GENERIC';
|
||||
}
|
||||
|
||||
// ── Tool result types ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ToolResult<T> {
|
||||
content: { type: 'text'; text: string } | { type: 'json'; json: T };
|
||||
}
|
||||
|
||||
export function textResult(text: string): ToolResult<never> {
|
||||
return { content: { type: 'text', text } };
|
||||
}
|
||||
|
||||
export function jsonResult<T>(json: T): ToolResult<T> {
|
||||
return { content: { type: 'json', json } };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// ── Per-workspace rate limiter ────────────────────────────────────────────────
|
||||
// Enforces: 100 diff exports per hour per workspace.
|
||||
// Uses a sliding window implemented with an in-process timestamp array.
|
||||
// For multi-instance deployments, replace with a Redis-backed implementation.
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const MAX_REQUESTS = 100;
|
||||
|
||||
const windows = new Map<string, number[]>();
|
||||
|
||||
export function checkRateLimit(workspaceId: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const cutoff = now - WINDOW_MS;
|
||||
|
||||
const prev = (windows.get(workspaceId) ?? []).filter(t => t > cutoff);
|
||||
const allowed = prev.length < MAX_REQUESTS;
|
||||
|
||||
// Only persist the new timestamp when the request is allowed.
|
||||
// Read the allowed decision from `prev.length` (before push) so concurrent
|
||||
// synchronous callers in the same event-loop tick all see the same baseline.
|
||||
if (allowed) {
|
||||
windows.set(workspaceId, [...prev, now]);
|
||||
}
|
||||
|
||||
const timestamps = allowed ? [...prev, now] : prev;
|
||||
const oldest = timestamps[0];
|
||||
const resetAt = oldest !== undefined ? oldest + WINDOW_MS : now + WINDOW_MS;
|
||||
|
||||
return {
|
||||
allowed,
|
||||
remaining: Math.max(0, MAX_REQUESTS - timestamps.length),
|
||||
resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRateLimitStatus(workspaceId: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const cutoff = now - WINDOW_MS;
|
||||
const timestamps = (windows.get(workspaceId) ?? []).filter(t => t > cutoff);
|
||||
const oldest = timestamps[0];
|
||||
return {
|
||||
allowed: timestamps.length < MAX_REQUESTS,
|
||||
remaining: Math.max(0, MAX_REQUESTS - timestamps.length),
|
||||
resetAt: oldest !== undefined ? oldest + WINDOW_MS : now + WINDOW_MS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { z } from 'zod';
|
||||
import { checkRateLimit } from './rate-limiter.js';
|
||||
import { jsonResult, textResult, MCP_ERROR } from './protocol.js';
|
||||
import type { ToolResult, JsonRpcError } from './protocol.js';
|
||||
import type { DiffStatus } from '@originmain/origin-graph';
|
||||
|
||||
// ── Tool context (injected per-connection) ────────────────────────────────────
|
||||
|
||||
export interface ToolContext {
|
||||
workspaceId: string;
|
||||
/** Adapter to the Origin Graph data layer */
|
||||
db: {
|
||||
getDiffsByStatus(workspaceId: string, status: DiffStatus): Promise<unknown[]>;
|
||||
getDiff(id: string): Promise<unknown>;
|
||||
getArtboard(id: string): Promise<unknown>;
|
||||
getDesignLanguageFile(workspaceId: string): Promise<unknown | null>;
|
||||
updateDiffStatus(id: string, status: DiffStatus, notes?: string): Promise<void>;
|
||||
};
|
||||
/** Adapter to the AI layer (for ask_design_agent) */
|
||||
ai: {
|
||||
answerAgentQuestion(diffId: string, question: string, artboardContext: unknown): Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tool definition ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: z.ZodTypeAny;
|
||||
execute(params: unknown, ctx: ToolContext): Promise<ToolResult<unknown> | JsonRpcError>;
|
||||
}
|
||||
|
||||
// ── Rate-limited wrapper ──────────────────────────────────────────────────────
|
||||
|
||||
function rateGuard(workspaceId: string): JsonRpcError | null {
|
||||
const { allowed } = checkRateLimit(workspaceId);
|
||||
if (!allowed) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: MCP_ERROR.RATE_LIMITED, message: 'Rate limit exceeded: 100 diff exports per hour per workspace' },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function notFound(id: string, type: string): JsonRpcError {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: MCP_ERROR.NOT_FOUND, message: `${type} ${id} not found` },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tool: get_pending_diffs ───────────────────────────────────────────────────
|
||||
|
||||
const GetPendingDiffsInput = z.object({
|
||||
workspace_id: z.string().uuid(),
|
||||
artboard_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const getPendingDiffs: McpTool = {
|
||||
name: 'get_pending_diffs',
|
||||
description: 'Returns all IntentDiff objects with EXPORTED status for the workspace.',
|
||||
inputSchema: GetPendingDiffsInput,
|
||||
async execute(params, ctx) {
|
||||
const guard = rateGuard(ctx.workspaceId);
|
||||
if (guard) return guard;
|
||||
|
||||
const { artboard_id } = GetPendingDiffsInput.parse(params);
|
||||
const diffs = await ctx.db.getDiffsByStatus(ctx.workspaceId, 'EXPORTED');
|
||||
|
||||
const filtered = artboard_id
|
||||
? (diffs as Array<{ artboard_id: string }>).filter(d => d.artboard_id === artboard_id)
|
||||
: diffs;
|
||||
|
||||
return jsonResult(filtered);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Tool: get_artboard_context ────────────────────────────────────────────────
|
||||
|
||||
const GetArtboardContextInput = z.object({
|
||||
artboard_id: z.string().uuid(),
|
||||
});
|
||||
|
||||
const getArtboardContext: McpTool = {
|
||||
name: 'get_artboard_context',
|
||||
description: 'Returns full artboard metadata, component tree, design language file, and before/after screenshots.',
|
||||
inputSchema: GetArtboardContextInput,
|
||||
async execute(params, ctx) {
|
||||
const { artboard_id } = GetArtboardContextInput.parse(params);
|
||||
const artboard = await ctx.db.getArtboard(artboard_id);
|
||||
if (artboard == null) return notFound(artboard_id, 'Artboard');
|
||||
const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId);
|
||||
return jsonResult({ artboard, designLanguageFile: dlf });
|
||||
},
|
||||
};
|
||||
|
||||
// ── Tool: ask_design_agent ────────────────────────────────────────────────────
|
||||
|
||||
const AskDesignAgentInput = z.object({
|
||||
diff_id: z.string().uuid(),
|
||||
question: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
const askDesignAgent: McpTool = {
|
||||
name: 'ask_design_agent',
|
||||
description: 'Ask the design AI agent a question about a specific diff. Returns a Claude-generated answer with visual reference.',
|
||||
inputSchema: AskDesignAgentInput,
|
||||
async execute(params, ctx) {
|
||||
const { diff_id, question } = AskDesignAgentInput.parse(params);
|
||||
|
||||
// Fetch the diff first to resolve its artboard_id, then fetch the artboard.
|
||||
const diff = await ctx.db.getDiff(diff_id);
|
||||
if (diff == null) return notFound(diff_id, 'IntentDiff');
|
||||
const artboardId = (diff as { artboard_id: string }).artboard_id;
|
||||
const artboard = await ctx.db.getArtboard(artboardId);
|
||||
if (artboard == null) return notFound(artboardId, 'Artboard');
|
||||
|
||||
const answer = await ctx.ai.answerAgentQuestion(diff_id, question, artboard);
|
||||
return textResult(answer);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Tool: update_diff_status ──────────────────────────────────────────────────
|
||||
|
||||
const UpdateDiffStatusInput = z.object({
|
||||
diff_id: z.string().uuid(),
|
||||
status: z.enum(['IMPLEMENTED', 'BLOCKED']),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const updateDiffStatus: McpTool = {
|
||||
name: 'update_diff_status',
|
||||
description: 'Acknowledges implementation or reports a block. Updates the diff status in the Origin Graph.',
|
||||
inputSchema: UpdateDiffStatusInput,
|
||||
async execute(params, ctx) {
|
||||
const { diff_id, status, notes } = UpdateDiffStatusInput.parse(params);
|
||||
await ctx.db.updateDiffStatus(diff_id, status, notes);
|
||||
return textResult(`Diff ${diff_id} status updated to ${status}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Tool: get_design_language ─────────────────────────────────────────────────
|
||||
|
||||
const GetDesignLanguageInput = z.object({
|
||||
workspace_id: z.string().uuid(),
|
||||
});
|
||||
|
||||
const getDesignLanguage: McpTool = {
|
||||
name: 'get_design_language',
|
||||
description: 'Returns the team\'s active Design Language File for local validation by the coding agent.',
|
||||
inputSchema: GetDesignLanguageInput,
|
||||
async execute(params, ctx) {
|
||||
// Validate the input even though we use the connection-scoped workspaceId.
|
||||
// This ensures MCP clients send well-formed requests and the schema is enforced.
|
||||
GetDesignLanguageInput.parse(params);
|
||||
const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId);
|
||||
if (!dlf) return textResult('No design language file configured for this workspace.');
|
||||
return jsonResult(dlf);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Tool registry ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const TOOLS: McpTool[] = [
|
||||
getPendingDiffs,
|
||||
getArtboardContext,
|
||||
askDesignAgent,
|
||||
updateDiffStatus,
|
||||
getDesignLanguage,
|
||||
];
|
||||
|
||||
export const TOOL_MAP = new Map(TOOLS.map(t => [t.name, t]));
|
||||
|
||||
/** Returns the MCP-spec JSON Schema listing for all tools. */
|
||||
export function getToolList() {
|
||||
return TOOLS.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: { type: 'object', ...zodToJsonSchema(t.inputSchema) },
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Minimal Zod → JSON Schema ─────────────────────────────────────────────────
|
||||
// Handles the subset of Zod types used by the 5 tools above.
|
||||
// Throws at startup if an unsupported type is encountered — fail loud, not silent.
|
||||
|
||||
function zodToJsonSchema(schema: z.ZodTypeAny): { properties?: Record<string, unknown>; required?: string[] } {
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = schema.shape as Record<string, z.ZodTypeAny>;
|
||||
const properties: Record<string, unknown> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, field] of Object.entries(shape)) {
|
||||
properties[key] = zodFieldToSchema(field);
|
||||
if (!(field instanceof z.ZodOptional)) {
|
||||
required.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return { properties, ...(required.length > 0 ? { required } : {}) };
|
||||
}
|
||||
throw new Error(`zodToJsonSchema: unsupported top-level type ${schema.constructor.name}`);
|
||||
}
|
||||
|
||||
function zodFieldToSchema(field: z.ZodTypeAny): unknown {
|
||||
if (field instanceof z.ZodOptional) return zodFieldToSchema(field.unwrap());
|
||||
if (field instanceof z.ZodString) return { type: 'string' };
|
||||
if (field instanceof z.ZodEnum) return { type: 'string', enum: field.options as string[] };
|
||||
if (field instanceof z.ZodNumber) return { type: 'number' };
|
||||
if (field instanceof z.ZodBoolean) return { type: 'boolean' };
|
||||
throw new Error(`zodToJsonSchema: unsupported field type ${field.constructor.name}`);
|
||||
}
|
||||
Reference in New Issue
Block a user