made tiny updates

This commit is contained in:
SinachPat
2026-05-06 18:51:17 +01:00
parent fdf64ee72c
commit f21969f018
25 changed files with 2450 additions and 655 deletions
@@ -42,18 +42,30 @@ The server is pre-configured in \`.claude/settings.json\`.
### Available MCP Tools
${toolLines}
### How intents arrive
The Origin canvas pushes design intent diffs in two ways:
1. **SSE push (primary):** The MCP server sends an \`INTENT_RECEIVED\` event over the SSE
connection automatically when the designer exports a change. You do not need to poll.
2. **Poll (fallback):** Call \`push_intent\` with \`{ workspace_id }\` to drain any pending
intents. Use this at session start or when you suspect a missed push.
### Implementation Workflow
1. Call \`push_intent\` to receive any pending design intent diffs from the Origin canvas.
2. Locate the component file using \`resolve_component\` (pass the \`nodeId\` from the intent).
3. Apply the change to the source file — the intent's \`codeDiff\` contains the expected before/after.
1. Wait for an \`INTENT_RECEIVED\` SSE event, or call \`push_intent\` to fetch pending intents.
2. Each intent includes a \`component.name\` — call \`resolve_component\` with that name to locate
the source file and line number (e.g. \`{ component_name: "DashboardCard" }\`).
3. Apply the change to the source file. When \`codeDiff\` is present and \`confidence\` is
\`"exact"\`, apply it verbatim. When \`"approximate"\`, use it as a guide and refine as needed.
4. After applying, call \`update_diff_status\` with \`status: "IMPLEMENTED"\` and the \`intentId\`.
5. If the diff cannot be applied for any reason, call \`update_diff_status\` with \`status: "BLOCKED"\`
and a \`reason\` string describing why (e.g. "Component not found in file", "File is read-only",
"Diff conflicts with current file state"). The designer will see this reason in the Origin canvas.
### Important: Always close the loop with update_diff_status
Every intent received via \`push_intent\` MUST be closed with \`update_diff_status\` — either
IMPLEMENTED or BLOCKED. An intent left in EXPORTED state will be retried on the next session.
Every intent received — whether via SSE push or \`push_intent\` poll — MUST be closed with
\`update_diff_status\` using either \`IMPLEMENTED\` or \`BLOCKED\`. An intent left in \`EXPORTED\`
state will be retried on the next session.
### Design Language
When token keys are present in the intent changes (\`tokenKey\` field), write \`var(--token-name)\`
+1 -1
View File
@@ -8,7 +8,7 @@ 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, dispatchTool, storePendingIntent, drainPendingIntents, registerIndexer, getIndexerUrl, heartbeatIndexer } from './tools.js';
export { TOOLS, TOOL_MAP, getToolList, dispatchTool, storePendingIntent, drainPendingIntents, registerIndexer, getIndexerUrl, heartbeatIndexer, registerSseClient } from './tools.js';
export type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js';
export { generateCursorConfig } from './adapters/cursor.js';
+125 -59
View File
@@ -13,7 +13,7 @@
*/
import { textResult, jsonResult } from './protocol.js';
import type { ToolResult } from './protocol.js';
import type { ToolResult, IntentMessage, IntentChange, IntentReceivedPush } from './protocol.js';
// ── Tool descriptor shape (MCP tools/list schema) ─────────────────────────────
@@ -66,13 +66,19 @@ const pendingIntents = new Map<string, IntentRecord[]>();
/**
* Store an intent pushed by the canvas (called from the /api/intent route).
* Returns the generated intentId.
*
* @param supplyIntentId Pass the Supabase UUID when you've already created the
* DB row — the in-memory ID must match so that the agent's
* `update_diff_status` tool can call `.eq('id', intentId)`.
* If omitted, a local opaque ID is generated (dev/offline mode).
* Returns the intentId actually stored.
*/
export function storePendingIntent(
workspaceId: string,
intent: Omit<IntentRecord, 'intentId' | 'workspaceId' | 'createdAt'>,
supplyIntentId?: string,
): string {
const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const intentId = supplyIntentId ?? `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const record: IntentRecord = {
intentId,
workspaceId,
@@ -82,9 +88,79 @@ export function storePendingIntent(
const queue = pendingIntents.get(workspaceId) ?? [];
queue.push(record);
pendingIntents.set(workspaceId, queue);
// Push INTENT_RECEIVED to any SSE-connected agents for this workspace.
const push: IntentReceivedPush = {
type: 'INTENT_RECEIVED',
intent: recordToIntentMessage(record),
};
pushToSseClients(workspaceId, `data: ${JSON.stringify(push)}\n\n`);
return intentId;
}
// ── SSE client registry ───────────────────────────────────────────────────────
// Maps workspaceId → set of send callbacks registered by connected SSE clients.
//
// Same in-process design as pendingIntents / indexerRegistry — for production
// multi-instance deployments, replace with Redis pub/sub or Supabase Realtime.
type SseSendFn = (chunk: string) => void;
const sseClients = new Map<string, Set<SseSendFn>>();
/**
* Register an SSE send callback for a workspace.
* Returns an unsubscribe function — call it when the SSE connection closes.
*/
export function registerSseClient(workspaceId: string, send: SseSendFn): () => void {
let set = sseClients.get(workspaceId);
if (!set) { set = new Set(); sseClients.set(workspaceId, set); }
set.add(send);
return () => {
set!.delete(send);
if (set!.size === 0) sseClients.delete(workspaceId);
};
}
/** Broadcast a raw SSE chunk to all connected clients for a workspace. */
function pushToSseClients(workspaceId: string, chunk: string): void {
const clients = sseClients.get(workspaceId);
if (!clients) return;
for (const send of clients) {
try { send(chunk); } catch { /* client disconnected — will be deregistered on abort */ }
}
}
/** Convert an IntentRecord to the IntentMessage shape for SSE / push_intent JSON response. */
function recordToIntentMessage(record: IntentRecord): IntentMessage {
let changes: IntentChange[] = [];
try {
const parsed = JSON.parse(record.patchJson) as unknown;
const patches: Array<{ property?: string; value?: string; previousValue?: string }> =
Array.isArray(parsed) ? parsed : ((parsed as { patches?: unknown[] }).patches ?? []);
changes = patches.map((p) => ({
type: 'style' as const,
cssProperty: p.property ?? '',
from: p.previousValue,
to: p.value ?? '',
confidence: 'approximate' as const,
}));
} catch { /* malformed patchJson — send empty changes list */ }
return {
intentId: record.intentId,
component: {
name: record.componentName,
// nodeId not stored in IntentRecord — agent uses component_name with
// resolve_component instead of a fiber-ID lookup.
nodeId: '',
props: {},
},
changes,
};
}
/**
* Drain all pending intents for a workspace (called by push_intent tool handler
* or by the agent when polling).
@@ -184,26 +260,20 @@ const PUSH_INTENT_TOOL: McpTool = {
const RESOLVE_COMPONENT_TOOL: McpTool = {
name: 'resolve_component',
description:
'Resolve the source file location for a component identified by its fiber node ID. ' +
'Returns the file path and line number where the component is defined in the codebase, ' +
'enabling the agent to navigate directly to the component source.',
'Resolve the source file location for a React component by name. ' +
'Proxies to the CLI indexer running in the workspace and returns the file path, ' +
'line number, props schema, and design tokens used by the component.',
inputSchema: {
type: 'object',
properties: {
artboard_id: {
type: 'string',
description: 'The artboard that contains the component.',
},
node_id: {
type: 'string',
description: 'The fiber node ID of the component (from SelectionOverlay / fiber tree).',
},
component_name: {
type: 'string',
description: 'Display name of the component (used as a fallback hint).',
description:
'The display name of the component to look up (e.g. "DashboardCard"). ' +
'Case-sensitive; use the name exactly as it appears in the INTENT_RECEIVED message.',
},
},
required: ['artboard_id', 'node_id'],
required: ['component_name'],
},
};
@@ -264,76 +334,72 @@ async function handlePushIntent(ctx: ToolContext): Promise<ToolResult<unknown>>
return textResult('Error: workspace_id does not match authenticated workspace.');
}
const intents = drainPendingIntents(wid);
const records = drainPendingIntents(wid);
if (intents.length === 0) {
return textResult('No pending design intents for this workspace.');
if (records.length === 0) {
return jsonResult({ intents: [], message: 'No pending design intents for this workspace.' });
}
const summary = intents.map((intent) => {
const patches = JSON.parse(intent.patchJson) as Array<{ property: string; value: string; previousValue?: string }>;
const lines = patches.map(
(p) => `${p.property}: ${p.previousValue ?? '(unknown)'}${p.value}`,
);
return [
`Intent ${intent.intentId}${intent.componentName} (${intent.strategy})`,
intent.summary,
...lines,
` artboardId: ${intent.artboardId}`,
].join('\n');
});
return textResult(
`${intents.length} pending design intent${intents.length !== 1 ? 's' : ''}:\n\n${summary.join('\n\n')}`,
);
// Return the full IntentMessage array so the agent can act on each intent
// without a separate round-trip. The same data is pushed proactively over
// SSE as INTENT_RECEIVED — this poll path exists as a fallback and for
// agents that do not maintain a persistent SSE connection.
const intents = records.map(recordToIntentMessage);
return jsonResult({ intents });
}
async function handleResolveComponent(ctx: ToolContext): Promise<ToolResult<unknown>> {
const params = ctx.params as Params;
const nodeId = typeof params['node_id'] === 'string' ? params['node_id'] : null;
const artboardId = typeof params['artboard_id'] === 'string' ? params['artboard_id'] : null;
const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : 'Component';
const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : null;
if (!nodeId || !artboardId) {
return textResult('Error: artboard_id and node_id are required.');
if (!componentName) {
return textResult('Error: component_name is required.');
}
// Check if a CLI indexer is registered for this workspace
// Check if a CLI indexer is registered for this workspace.
const indexerUrl = getIndexerUrl(ctx.workspaceId);
if (!indexerUrl) {
return textResult(
`No CLI indexer registered for workspace ${ctx.workspaceId}. ` +
'Run npx @originmain/cli dev to enable component resolution.',
'Run "npx @originmain/cli dev" to enable component resolution.',
);
}
// Proxy the resolution request to the CLI indexer
// Proxy to GET /components?name=<componentName> on the CLI indexer.
// The indexer returns ComponentEntry[] — we return the first exact or best match.
try {
const url = new URL('/resolve-component', indexerUrl);
url.searchParams.set('nodeId', nodeId);
url.searchParams.set('artboardId', artboardId);
url.searchParams.set('componentName', componentName);
const url = new URL('/components', indexerUrl);
url.searchParams.set('name', componentName);
const res = await fetch(url.toString(), {
signal: AbortSignal.timeout(5000),
});
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
return textResult(`Indexer returned ${res.status}: ${await res.text()}`);
}
const data = (await res.json()) as { filePath?: string; lineNumber?: number; column?: number };
if (!data.filePath) {
return textResult(`Component ${componentName} not found in index.`);
const entries = (await res.json()) as Array<{
name: string;
definitionFile: string;
relativeFile: string;
lineNumber: number;
props: Array<{ name: string; type: string; optional: boolean }>;
tokensUsed: string[];
}>;
if (!entries.length) {
return textResult(`Component "${componentName}" not found in index. Make sure the CLI indexer has indexed this file.`);
}
// Prefer exact name match; fall back to first fuzzy result.
const match = entries.find((e) => e.name === componentName) ?? entries[0]!;
return jsonResult({
filePath: data.filePath,
lineNumber: data.lineNumber ?? 1,
column: data.column ?? 1,
nodeId,
artboardId,
componentName,
name: match.name,
definitionFile: match.definitionFile,
relativeFile: match.relativeFile,
lineNumber: match.lineNumber,
props: match.props,
tokensUsed: match.tokensUsed,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);