improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 09:09:55 +01:00
parent 2911f22df8
commit 97315846be
70 changed files with 4967 additions and 10 deletions
@@ -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 };
}
+68
View File
@@ -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 };
}
+17 -1
View File
@@ -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';
+69
View File
@@ -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 } };
}
+52
View File
@@ -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,
};
}
+216
View File
@@ -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}`);
}