diff --git a/apps/mcp-app/README.md b/apps/mcp-app/README.md index 7613701485ff..713705602135 100644 --- a/apps/mcp-app/README.md +++ b/apps/mcp-app/README.md @@ -8,33 +8,43 @@ The app has two parts: a **server** and a **widget**. ### Server -The server registers MCP tools (`create_shapes`, `update_shapes`, `delete_shapes`, `diagram_drawing_read_me`) and serves the widget HTML as an MCP App resource. +The server runs in Cloudflare Workers via `src/worker.ts`, using a Durable Object (`TldrawMCP`) backed by SQLite for persistent checkpoint storage. -There are two entry points: +It exposes: -- `main.ts` — Node.js stdio transport, for local clients like Claude Desktop and Cursor -- `src/worker.ts` — Cloudflare Workers with a Durable Object (`TldrawMCP`) backed by SQLite for persistent checkpoint storage - -Both entry points share tool registration logic in `src/register-tools.ts`. +- `search` — query the extracted Editor API spec in a sandboxed dynamic worker +- `exec` — execute JavaScript against the live editor in the widget via a pending-request callback bridge +- `_exec_callback` — app-only tool the widget calls to resolve a pending `exec` request +- `save_checkpoint` / `read_checkpoint` — app-only tools used by the widget for checkpoint persistence ### Widget The widget is a React app (`src/widget/mcp-app.tsx`) that renders a full tldraw canvas inside the MCP host's iframe. -The widget handles streaming previews (shapes appear as the model streams tool arguments), and syncing state back to the server. +When the AI calls `exec`, the server creates a pending request and the widget picks it up, runs the code through a focused editor proxy (`src/widget/focused/`) that translates between an AI-friendly shape format (simple string IDs, flat `_type` shapes) and tldraw's internal `TLShape`/`TLShapeId` types, then calls `_exec_callback` to resolve the pending request with the result. Canvas state is checkpointed to the Durable Object's SQLite database and to the browser's local storage. ## Developing -Run all commands from `apps/mcp-app`. +### Prerequisites + +The widget build depends on generated files (`editor-api.json`, `method-map.json`) that are extracted from the editor's TypeScript declarations. Before you can develop or build the mcp-app, you need to build the core packages first: + +```bash +# from the repo root +yarn build +``` + +This produces the `.tsbuild/` output that `yarn extract-api` reads from. The `build` and `dev` scripts run `extract-api` automatically, so you don't need to call it separately. ### Package scripts +Run all commands from `apps/mcp-app`. + | Command | What it does | | ----------------- | -------------------------------------------------------------------------------------------------- | | `yarn build` | Build the widget HTML | | `yarn dev` | Build widget + start local Cloudflare worker (HTTP MCP on `localhost:8787`) | -| `yarn dev:stdio` | Build widget + Start a local stdio MCP server | -| `yarn dev:tunnel` | Build widget + Start a Cloudflare tunnel + local worker with `WORKER_ORIGIN` set to the tunnel URL | +| `yarn dev:tunnel` | Build widget + start a Cloudflare tunnel + local worker with `WORKER_ORIGIN` set to the tunnel URL | | `yarn deploy` | Build widget + deploy the Cloudflare worker to production | `yarn dev:tunnel` requires the `cloudflared` CLI to be installed on your machine. @@ -43,7 +53,7 @@ The worker defaults to production-safe behavior in `wrangler.toml`, including se ### Cursor setup -Add these three servers in `~/.cursor/mcp.json`: +Add these two servers in `~/.cursor/mcp.json`: ```json { @@ -55,28 +65,14 @@ Add these three servers in `~/.cursor/mcp.json`: "tldraw-local": { "command": "npx", "args": ["-y", "mcp-remote", "http://127.0.0.1:8787/mcp"] - }, - "tldraw-local-stdio": { - "command": "yarn", - "args": [ - "--cwd", - "/tldraw/apps/mcp-app", - "run", - "-s", - "tsx", - "main.ts", - "--stdio" - ] } } } ``` -`--cwd` ensures Cursor launches in the app folder. `-s` stops yarn from writing non-JSON noise to stdout, which breaks the stdio transport. - ### Claude Desktop local setup -For local Claude Desktop development, use `claude_desktop_config.json` for the local HTTP and stdio servers: +For local Claude Desktop development, use `claude_desktop_config.json` with the local HTTP server: ```json { @@ -84,18 +80,6 @@ For local Claude Desktop development, use `claude_desktop_config.json` for the l "tldraw-local": { "command": "npx", "args": ["-y", "mcp-remote", "http://127.0.0.1:8787/mcp"] - }, - "tldraw-local-stdio": { - "command": "yarn", - "args": [ - "--cwd", - "/tldraw/apps/mcp-app", - "run", - "-s", - "tsx", - "main.ts", - "--stdio" - ] } } } @@ -130,7 +114,7 @@ ChatGPT requires an HTTPS origin, so you need a Cloudflare tunnel. You must be a ### Iteration loop 1. Make code changes in `apps/mcp-app` -2. Run the relevant script (`dev`, `dev:stdio`, or `dev:tunnel`) +2. Run the relevant script (`dev` or `dev:tunnel`) 3. Disconnect and reconnect the MCP server in your client (or reload the page/app) 4. When making widget changes, make sure to rebuild, either by running `yarn build` or rerunning any of the dev scripts. diff --git a/apps/mcp-app/main.ts b/apps/mcp-app/main.ts deleted file mode 100644 index 4c9c31a6f94d..000000000000 --- a/apps/mcp-app/main.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Entry point: start the MCP server with stdio transport. */ - -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { createServer } from './server' - -async function main() { - const server = createServer() - const transport = new StdioServerTransport() - await server.connect(transport) - console.error('tldraw MCP server running on stdio') -} - -main().catch((err) => { - console.error('Failed to start stdio server:', err) - process.exit(1) -}) diff --git a/apps/mcp-app/package.json b/apps/mcp-app/package.json index 2dcdc3c1ebcc..a4a3ff46f273 100644 --- a/apps/mcp-app/package.json +++ b/apps/mcp-app/package.json @@ -5,11 +5,12 @@ "private": true, "type": "module", "scripts": { - "build:widget": "node ../../packages/tldraw/scripts/copy-css-files.mjs && vite build && mv dist/index.html dist/mcp-app.html", + "extract-api": "tsx scripts/extract-editor-api.ts", + "generate-agent-sdk-docs": "yarn run -T tsgo --build ../../packages/editor/tsconfig.json ../../packages/store/tsconfig.json ../../packages/tlschema/tsconfig.json && yarn extract-api", + "build:widget": "yarn generate-agent-sdk-docs && node ../../packages/tldraw/scripts/copy-css-files.mjs && vite build && mv dist/index.html dist/mcp-app.html", "build": "yarn build:widget", "dev": "yarn dev:http", - "dev:stdio": "yarn build && tsx main.ts --stdio", - "dev:http": "yarn build && wrangler dev --var \"MCP_IS_DEV:true\"", + "dev:http": "yarn build && wrangler dev --var \"MCP_IS_DEV:true\" --var \"WORKER_ORIGIN:http://localhost:8787\"", "dev:tunnel": "yarn build && bash dev-tunnel.sh", "deploy": "yarn build && wrangler deploy", "lint": "yarn run -T tsx ../../internal/scripts/lint.ts", diff --git a/apps/mcp-app/scripts/extract-editor-api.ts b/apps/mcp-app/scripts/extract-editor-api.ts new file mode 100644 index 000000000000..bd23b4467f5e --- /dev/null +++ b/apps/mcp-app/scripts/extract-editor-api.ts @@ -0,0 +1,1374 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import ts from 'typescript' + +const scriptPath = fileURLToPath(import.meta.url) +const __dirname = path.dirname(scriptPath) +const distDir = path.join(__dirname, '..', 'dist') +const outPath = path.join(distDir, 'editor-api.json') +const methodMapOutPath = path.join(distDir, 'method-map.json') +const formatTsPath = path.join(__dirname, '..', 'src', 'widget', 'focused', 'format.ts') +const execHelpersPath = path.join(__dirname, '..', 'src', 'widget', 'exec-helpers.ts') + +// In the tldraw monorepo, packages build to .tsbuild/ +const repoRoot = path.resolve(__dirname, '..', '..', '..') +const editorDtsPath = path.join( + repoRoot, + 'packages', + 'editor', + '.tsbuild', + 'lib', + 'editor', + 'Editor.d.ts' +) +const storeDtsPath = path.join(repoRoot, 'packages', 'store', '.tsbuild', 'index.d.ts') +const tlschemaDtsPath = path.join(repoRoot, 'packages', 'tlschema', '.tsbuild', 'index.d.ts') + +for (const p of [editorDtsPath, storeDtsPath, tlschemaDtsPath]) { + if (!fs.existsSync(p)) { + console.error(`Missing: ${p}\nRun 'yarn lazy build' first to generate .d.ts files.`) + process.exit(1) + } +} + +// --- Types --- + +interface ExtractedParam { + name: string + description: string +} + +interface ExtractedMember { + name: string + kind: 'method' | 'property' | 'getter' + signature: string + description: string + params: ExtractedParam[] + examples: string[] + category: string +} + +interface ExtractedTypeProperty { + name: string + signature: string + description: string + optional: boolean +} + +interface ExtractedShapeType { + name: string + shapeType: string + signature: string + description: string + propsType: string + propsDescription: string + props: ExtractedTypeProperty[] +} + +interface ExtractedTypesSection { + shapeTypes: string[] + shapes: ExtractedShapeType[] +} + +interface ExtractedTypeMember { + name: string + kind: 'method' | 'property' | 'getter' + signature: string + description: string + params: ExtractedParam[] + examples: string[] + optional: boolean + static: boolean +} + +interface ExtractedNamedType { + name: string + kind: 'class' | 'interface' | 'type' | 'function' | 'const' + signature: string + description: string + params?: ExtractedParam[] + examples?: string[] + members?: ExtractedTypeMember[] + aliasedTo?: string + resolvedType?: ExtractedNamedType + relatedTypes?: ExtractedNamedType[] +} + +interface ExtractedExecHelper { + name: string + source: 'local' | 'tldraw' + origin: string + signature: string + description: string + params: ExtractedParam[] + examples: string[] + typeInfo?: ExtractedNamedType +} + +interface ExtractedExecSection { + helperCount: number + helpers: ExtractedExecHelper[] +} + +type NamedDeclaration = + | ts.ClassDeclaration + | ts.InterfaceDeclaration + | ts.TypeAliasDeclaration + | ts.FunctionDeclaration + | ts.VariableDeclaration + +interface DeclarationContext { + program: ts.Program + checker: ts.TypeChecker + declarations: Map + sourceFiles: Map +} + +// --- Helpers --- + +function categorize(name: string): string { + if (/camera/i.test(name)) return 'camera' + if (/viewport|screenToPage|pageToScreen|pageToViewport|viewportToPage/i.test(name)) + return 'viewport' + if (/^(get|set|create|update|delete|reorder|reparent).*shape/i.test(name)) return 'shapes' + if (/^(get|has)Shape/i.test(name)) return 'shapes' + if (/shapeUtil/i.test(name)) return 'shapes' + if (/^(select|deselect|getSelect|setSelect|clearSelect|getSelectedShape)/i.test(name)) + return 'selection' + if (/selected/i.test(name)) return 'selection' + if (/^(get|set|create|delete|move|reorder|duplicate).*page/i.test(name)) return 'pages' + if (/^(undo|redo|mark|bail|squash|run$|history|batch)/i.test(name)) return 'history' + if (/^(zoom|pan|stopFollowing|startFollowing|slideCamera|resetZoom|zoomTo)/i.test(name)) + return 'zoom' + if (/binding/i.test(name)) return 'bindings' + if (/^(group|ungroup)/i.test(name)) return 'grouping' + if ( + /^(nudge|align|distribute|stack|stretch|pack|flip|rotate|resize|moveShapes|translate)/i.test( + name + ) + ) + return 'transform' + if (/^(isIn|getPath|setCurrentTool|getCurrentTool)/i.test(name)) return 'tools' + if (/asset/i.test(name)) return 'assets' + if (/style|opacity|color|font/i.test(name)) return 'styles' + if (/^(get|set).*Hinting/i.test(name)) return 'hinting' + if (/^(get|set).*Erasing/i.test(name)) return 'erasing' + if (/^(get|set).*Cropping/i.test(name)) return 'cropping' + if (/^(get|set).*Editing/i.test(name)) return 'editing' + if (/^(get|set).*Hovering/i.test(name)) return 'hovering' + if (/^(get|set).*Focus/i.test(name)) return 'focus' + if (/^(get|set).*Dragging/i.test(name)) return 'dragging' + if (/snap/i.test(name)) return 'snapping' + if (/export|toImage|toSvg|toBlobPromise/i.test(name)) return 'export' + if (/cursor/i.test(name)) return 'cursor' + if (/instance/i.test(name)) return 'instance' + if (/store/i.test(name)) return 'store' + if (/^(dispose|isDisposed)/i.test(name)) return 'lifecycle' + return 'other' +} + +function extractJsDoc( + member: ts.Node, + sourceFile: ts.SourceFile +): { description: string; params: ExtractedParam[]; examples: string[] } { + const empty = { description: '', params: [], examples: [] } + + const ranges = ts.getLeadingCommentRanges(sourceFile.text, member.getFullStart()) + if (!ranges) return empty + + const jsdocRanges = ranges.filter((r) => sourceFile.text.slice(r.pos, r.pos + 3) === '/**') + const jsdocRange = jsdocRanges[jsdocRanges.length - 1] + if (!jsdocRange) return empty + + const raw = sourceFile.text.slice(jsdocRange.pos, jsdocRange.end) + + if (raw.includes('Excluded from this release type')) { + return { description: '__EXCLUDED__', params: [], examples: [] } + } + + const lines = raw + .replace(/^\/\*\*\s*/, '') + .replace(/\s*\*\/$/, '') + .split('\n') + .map((l) => l.replace(/^\s*\*\s?/, '')) + + const descLines: string[] = [] + const tags: Array<{ tag: string; text: string }> = [] + + for (const line of lines) { + const tagMatch = line.match(/^@(\w+)\s*(.*)/) + if (tagMatch) { + tags.push({ tag: tagMatch[1], text: tagMatch[2] }) + } else if (tags.length > 0) { + tags[tags.length - 1].text += '\n' + line + } else { + descLines.push(line) + } + } + + const description = descLines.join('\n').trim() + const params = tags + .filter((t) => t.tag === 'param') + .map((t) => { + const m = t.text.match(/^(\w+)\s*-\s*(.*)/) + return m ? { name: m[1], description: m[2].trim() } : null + }) + .filter((p): p is ExtractedParam => p !== null) + + const examples = tags + .filter((t) => t.tag === 'example') + .map((t) => + t.text + .replace(/```ts\n?/g, '') + .replace(/```\n?/g, '') + .trim() + ) + .filter((e) => e.length > 0) + + return { description, params, examples } +} + +function getPropertyName(name: ts.PropertyName | ts.BindingName | undefined): string | undefined { + if (!name) return undefined + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text + } + return undefined +} + +function isExcludedComment(member: ts.Node, sourceFile: ts.SourceFile): boolean { + const ranges = ts.getLeadingCommentRanges(sourceFile.text, member.getFullStart()) + if (!ranges) return false + const lastRange = ranges[ranges.length - 1] + return sourceFile.text + .slice(lastRange.pos, lastRange.end) + .includes('Excluded from this release type') +} + +// --- Declaration context --- + +function createDeclarationContext(entryPaths: string[]): DeclarationContext { + const program = ts.createProgram(entryPaths, { + target: ts.ScriptTarget.ES2020, + jsx: ts.JsxEmit.ReactJSX, + moduleResolution: ts.ModuleResolutionKind.Node10, + }) + const declarations = new Map() + const sourceFiles = new Map() + const indexedSourceFiles = new Set(entryPaths.map((entryPath) => path.resolve(entryPath))) + + for (const sourceFile of program.getSourceFiles()) { + sourceFiles.set(sourceFile.fileName, sourceFile) + const shouldIndex = + indexedSourceFiles.has(path.resolve(sourceFile.fileName)) || + sourceFile.fileName.includes('/packages/') || + sourceFile.fileName.includes('/node_modules/@tldraw/') || + sourceFile.fileName.includes('/node_modules/tldraw/') + if (!shouldIndex) continue + + ts.forEachChild(sourceFile, (node) => { + if ( + (ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isFunctionDeclaration(node)) && + node.name + ) { + declarations.set(node.name.text, node) + return + } + + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + declarations.set(declaration.name.text, declaration) + } + } + } + }) + } + + return { + program, + checker: program.getTypeChecker(), + declarations, + sourceFiles, + } +} + +function getDeclarationSourceFile(declaration: NamedDeclaration): ts.SourceFile { + return declaration.getSourceFile() +} + +function getDeclarationKind(declaration: NamedDeclaration): ExtractedNamedType['kind'] { + if (ts.isClassDeclaration(declaration)) return 'class' + if (ts.isInterfaceDeclaration(declaration)) return 'interface' + if (ts.isFunctionDeclaration(declaration)) return 'function' + if (ts.isVariableDeclaration(declaration)) return 'const' + return 'type' +} + +function getDeclarationSignature( + declaration: NamedDeclaration, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker +): string { + if (ts.isTypeAliasDeclaration(declaration)) { + return declaration.type.getText(sourceFile) + } + + if (ts.isFunctionDeclaration(declaration)) { + try { + const signature = checker.getSignatureFromDeclaration(declaration) + if (signature) { + return checker.signatureToString( + signature, + declaration, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature + ) + } + return checker.typeToString( + checker.getTypeAtLocation(declaration), + declaration, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature + ) + } catch { + return '(unknown)' + } + } + + if (ts.isVariableDeclaration(declaration)) { + try { + if (declaration.type) return declaration.type.getText(sourceFile) + return checker.typeToString( + checker.getTypeAtLocation(declaration), + declaration, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature + ) + } catch { + return '(unknown)' + } + } + + const heritage = declaration.heritageClauses + ?.map((clause) => clause.getText(sourceFile)) + .filter(Boolean) + .join(' ') + + if (ts.isClassDeclaration(declaration)) { + const abstractPrefix = declaration.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword + ) + ? 'abstract ' + : '' + return `${abstractPrefix}class ${declaration.name?.text ?? '(anonymous)'}${ + heritage ? ` ${heritage}` : '' + }` + } + + return `interface ${declaration.name.text}${heritage ? ` ${heritage}` : ''}` +} + +function getMemberSignature( + member: ts.ClassElement | ts.TypeElement, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker +): string { + let signature = '(unknown)' + try { + if ( + (ts.isPropertyDeclaration(member) || + ts.isPropertySignature(member) || + ts.isMethodDeclaration(member) || + ts.isMethodSignature(member)) && + member.type + ) { + signature = member.type.getText(sourceFile) + } else { + signature = checker.typeToString( + checker.getTypeAtLocation(member), + member, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature + ) + } + } catch { + // keep fallback + } + return signature +} + +function extractTypeMembers( + declaration: ts.ClassDeclaration | ts.InterfaceDeclaration, + context: DeclarationContext +): ExtractedTypeMember[] { + const sourceFile = getDeclarationSourceFile(declaration) + const members: ExtractedTypeMember[] = [] + + for (const member of declaration.members) { + if (isExcludedComment(member, sourceFile)) continue + + if (ts.isClassDeclaration(declaration)) { + const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined + if ( + modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword + ) + ) { + continue + } + } + + if (ts.isConstructorDeclaration(member)) continue + + const name = 'name' in member ? getPropertyName(member.name) : undefined + if (!name || name.startsWith('_')) continue + + let kind: ExtractedTypeMember['kind'] + if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) { + kind = 'method' + } else if (ts.isGetAccessorDeclaration(member) || ts.isGetAccessor(member)) { + kind = 'getter' + } else if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) { + kind = 'property' + } else { + continue + } + + const jsdoc = extractJsDoc(member, sourceFile) + if (jsdoc.description === '__EXCLUDED__') continue + + members.push({ + name, + kind, + signature: getMemberSignature(member, sourceFile, context.checker), + description: jsdoc.description, + params: jsdoc.params, + examples: jsdoc.examples, + optional: 'questionToken' in member && !!member.questionToken, + static: + ts.isClassDeclaration(declaration) && + (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some( + (modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword + ) === true, + }) + } + + return members +} + +function extractNamedType( + name: string, + context: DeclarationContext, + visited = new Set() +): ExtractedNamedType | undefined { + if (visited.has(name)) return undefined + const declaration = context.declarations.get(name) + if (!declaration) return undefined + + visited.add(name) + + const sourceFile = getDeclarationSourceFile(declaration) + const result: ExtractedNamedType = { + name, + kind: getDeclarationKind(declaration), + signature: getDeclarationSignature(declaration, sourceFile, context.checker), + description: extractJsDoc(declaration, sourceFile).description, + params: extractJsDoc(declaration, sourceFile).params, + examples: extractJsDoc(declaration, sourceFile).examples, + } + + if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) { + result.members = extractTypeMembers(declaration, context) + return result + } + + if (ts.isFunctionDeclaration(declaration) || ts.isVariableDeclaration(declaration)) { + return result + } + + result.aliasedTo = declaration.type.getText(sourceFile) + + if (ts.isTypeReferenceNode(declaration.type)) { + const baseTypeName = declaration.type.typeName.getText(sourceFile) + if (baseTypeName !== name) { + result.resolvedType = extractNamedType(baseTypeName, context, visited) + } + + const relatedTypeNames = (declaration.type.typeArguments ?? []) + .filter((arg): arg is ts.TypeReferenceNode => ts.isTypeReferenceNode(arg)) + .map((arg) => arg.getText(sourceFile)) + .filter((typeName) => { + if (typeName === baseTypeName) return false + const relatedDeclaration = context.declarations.get(typeName) + return !!relatedDeclaration && ts.isInterfaceDeclaration(relatedDeclaration) + }) + + const relatedTypes = relatedTypeNames + .map((typeName) => extractNamedType(typeName, context, visited)) + .filter((type): type is ExtractedNamedType => type !== undefined) + + if (relatedTypes.length > 0) { + result.relatedTypes = relatedTypes + } + } + + return result +} + +function getSymbolDeclaration( + symbol: ts.Symbol | undefined, + checker: ts.TypeChecker +): NamedDeclaration | undefined { + if (!symbol) return undefined + const resolvedSymbol = + symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol + const declaration = resolvedSymbol.declarations?.find( + (declaration): declaration is NamedDeclaration => + ts.isClassDeclaration(declaration) || + ts.isInterfaceDeclaration(declaration) || + ts.isTypeAliasDeclaration(declaration) || + ts.isFunctionDeclaration(declaration) || + ts.isVariableDeclaration(declaration) + ) + return declaration +} + +function extractNamedTypeFromDeclaration( + declaration: NamedDeclaration, + context: DeclarationContext +): ExtractedNamedType | undefined { + const name = ts.isVariableDeclaration(declaration) + ? ts.isIdentifier(declaration.name) + ? declaration.name.text + : undefined + : declaration.name?.text + if (!name) return undefined + if (!context.declarations.has(name)) { + context.declarations.set(name, declaration) + } + return extractNamedType(name, context) +} + +function findNode( + root: ts.Node, + predicate: (node: ts.Node) => node is T +): T | undefined { + let result: T | undefined + const visit = (node: ts.Node) => { + if (result) return + if (predicate(node)) { + result = node + return + } + ts.forEachChild(node, visit) + } + visit(root) + return result +} + +// --- Extract exec helpers from exec-helpers.ts --- + +function extractExecHelpers(): ExtractedExecSection { + const context = createDeclarationContext([ + execHelpersPath, + editorDtsPath, + storeDtsPath, + tlschemaDtsPath, + ]) + const sourceFile = context.sourceFiles.get(execHelpersPath) + if (!sourceFile) { + throw new Error(`Could not load source file: ${execHelpersPath}`) + } + + // Find the helpers object inside createExecHelpers function + const helpersDeclaration = findNode( + sourceFile, + (node): node is ts.VariableDeclaration => + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === 'helpers' && + !!node.initializer && + ts.isObjectLiteralExpression(node.initializer) + ) + + if ( + !helpersDeclaration || + !helpersDeclaration.initializer || + !ts.isObjectLiteralExpression(helpersDeclaration.initializer) + ) { + throw new Error('Could not find helpers object in exec-helpers.ts') + } + + // Collect tldraw imports + const tldrawImports = new Map() + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue + if ( + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== 'tldraw' + ) + continue + if ( + !statement.importClause?.namedBindings || + !ts.isNamedImports(statement.importClause.namedBindings) + ) { + continue + } + + for (const element of statement.importClause.namedBindings.elements) { + const localName = element.name.text + const importedName = element.propertyName?.text ?? localName + tldrawImports.set(localName, importedName) + } + } + + const helpers: ExtractedExecHelper[] = [] + + for (const property of helpersDeclaration.initializer.properties) { + if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) continue + + const helperName = getPropertyName(property.name) + if (!helperName) continue + + const initializer = ts.isPropertyAssignment(property) ? property.initializer : property.name + let typeInfo: ExtractedNamedType | undefined + let source: ExtractedExecHelper['source'] = 'local' + let origin = helperName + + if (ts.isIdentifier(initializer)) { + const importedName = tldrawImports.get(initializer.text) + if (importedName) { + source = 'tldraw' + origin = importedName + typeInfo = extractNamedType(importedName, context) + } + + if (!typeInfo) { + const symbol = context.checker.getSymbolAtLocation(initializer) + const declaration = getSymbolDeclaration(symbol, context.checker) + typeInfo = declaration ? extractNamedTypeFromDeclaration(declaration, context) : undefined + if (declaration && declaration.getSourceFile().fileName.includes('/packages/')) { + source = 'tldraw' + origin = ts.isVariableDeclaration(declaration) + ? ts.isIdentifier(declaration.name) + ? declaration.name.text + : 'tldraw' + : (declaration.name?.text ?? 'tldraw') + } + } + } else if (ts.isCallExpression(initializer) && ts.isIdentifier(initializer.expression)) { + // Factory function pattern: someFn(editor) — resolve the inner return type + const factoryName = initializer.expression.text + const declaration = context.declarations.get(factoryName) + if (declaration && ts.isFunctionDeclaration(declaration)) { + const returnStatement = findNode( + declaration, + (node): node is ts.ReturnStatement => + ts.isReturnStatement(node) && !!node.expression && ts.isArrowFunction(node.expression) + ) + if (returnStatement?.expression && ts.isArrowFunction(returnStatement.expression)) { + const returnJsDoc = extractJsDoc(returnStatement, sourceFile) + const declarationJsDoc = extractJsDoc(declaration, sourceFile) + typeInfo = { + name: helperName, + kind: 'function', + signature: context.checker.typeToString( + context.checker.getTypeAtLocation(returnStatement.expression), + returnStatement.expression, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature + ), + description: returnJsDoc.description || declarationJsDoc.description, + params: returnJsDoc.params.length > 0 ? returnJsDoc.params : declarationJsDoc.params, + examples: + returnJsDoc.examples.length > 0 ? returnJsDoc.examples : declarationJsDoc.examples, + } + } + } + source = 'local' + origin = helperName + } + + helpers.push({ + name: helperName, + source, + origin, + signature: typeInfo?.signature ?? '(unknown)', + description: typeInfo?.description ?? '', + params: typeInfo?.params ?? [], + examples: typeInfo?.examples ?? [], + typeInfo, + }) + } + + return { + helperCount: helpers.length, + helpers, + } +} + +// --- Extract Editor members --- + +function extract(): ExtractedMember[] { + const program = ts.createProgram([editorDtsPath, storeDtsPath, tlschemaDtsPath], { + target: ts.ScriptTarget.ES2020, + moduleResolution: ts.ModuleResolutionKind.Node10, + }) + const checker = program.getTypeChecker() + const sourceFile = program.getSourceFile(editorDtsPath) + if (!sourceFile) { + throw new Error(`Could not load source file: ${editorDtsPath}`) + } + + let editorClass: ts.ClassDeclaration | undefined + ts.forEachChild(sourceFile, (node) => { + if (ts.isClassDeclaration(node) && node.name?.text === 'Editor') { + editorClass = node + } + }) + + if (!editorClass) { + throw new Error('Could not find Editor class in .d.ts file') + } + + const members: ExtractedMember[] = [] + + for (const member of editorClass.members) { + const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined + if ( + modifiers?.some( + (m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword + ) + ) { + continue + } + + if (isExcludedComment(member, sourceFile)) continue + + const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined + if (!name) continue + if (name.startsWith('_')) continue + if (ts.isConstructorDeclaration(member)) continue + + let kind: 'method' | 'property' | 'getter' + if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) { + kind = 'method' + } else if (ts.isGetAccessorDeclaration(member) || ts.isGetAccessor(member)) { + kind = 'getter' + } else if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) { + kind = 'property' + } else { + continue + } + + const type = checker.getTypeAtLocation(member) + let signature: string + try { + signature = checker.typeToString( + type, + member, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature + ) + } catch { + signature = '(unknown)' + } + + const jsdoc = extractJsDoc(member, sourceFile) + if (jsdoc.description === '__EXCLUDED__') continue + + members.push({ + name, + kind, + signature, + description: jsdoc.description, + params: jsdoc.params, + examples: jsdoc.examples, + category: categorize(name), + }) + } + + return members +} + +// --- Extract focused shape types from format.ts --- + +const FOCUSED_SHAPE_INTERFACES = [ + 'FocusedGeoShape', + 'FocusedTextShape', + 'FocusedArrowShape', + 'FocusedLineShape', + 'FocusedNoteShape', + 'FocusedDrawShape', +] + +function toPascalCase(value: string) { + return value + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join('') +} + +function extractFocusedShapeTypes(): ExtractedTypesSection { + const context = createDeclarationContext([formatTsPath, editorDtsPath, tlschemaDtsPath]) + const sourceFile = context.sourceFiles.get(formatTsPath) + if (!sourceFile) { + throw new Error(`Could not load source file: ${formatTsPath}`) + } + + const allShapeTypes: string[] = [] + const shapes: ExtractedShapeType[] = [] + + for (const ifaceName of FOCUSED_SHAPE_INTERFACES) { + const declaration = context.declarations.get(ifaceName) + if (!declaration || !ts.isInterfaceDeclaration(declaration)) { + console.error(`Warning: could not find interface ${ifaceName} in format.ts`) + continue + } + + const ifaceSourceFile = declaration.getSourceFile() + const jsdoc = extractJsDoc(declaration, ifaceSourceFile) + + const props: ExtractedTypeProperty[] = [] + let shapeType = '' + const unionShapeTypes: string[] = [] + + for (const member of declaration.members) { + if (!ts.isPropertySignature(member) && !ts.isPropertyDeclaration(member)) continue + const propName = getPropertyName(member.name) + if (!propName) continue + + let signature = '(unknown)' + try { + const memberType = context.checker.getTypeAtLocation(member) + signature = context.checker.typeToString( + memberType, + member, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias + ) + } catch { + if (member.type) signature = member.type.getText(ifaceSourceFile) + } + + const propJsdoc = extractJsDoc(member, ifaceSourceFile) + + if (propName === '_type') { + const memberType = context.checker.getTypeAtLocation(member) + if (memberType.isStringLiteral()) { + shapeType = memberType.value + } else if (memberType.isUnion()) { + for (const t of memberType.types) { + if (t.isStringLiteral()) { + allShapeTypes.push(t.value) + unionShapeTypes.push(t.value) + } + } + } + } + + props.push({ + name: propName, + signature, + description: propJsdoc.description, + optional: !!member.questionToken, + }) + } + + if (shapeType && shapeType !== 'geo') { + allShapeTypes.push(shapeType) + } + + const displayName = ifaceName.replace(/^Focused/, '') + const propNames = props.map((p) => p.name).join(', ') + const signature = `{ ${propNames} }` + + if (unionShapeTypes.length > 0) { + for (const concreteShapeType of unionShapeTypes) { + const concreteName = `${toPascalCase(concreteShapeType)}Shape` + shapes.push({ + name: concreteName, + shapeType: concreteShapeType, + signature, + description: jsdoc.description, + propsType: `${concreteName}Props`, + propsDescription: jsdoc.description, + props: props.map((prop) => + prop.name === '_type' + ? { + ...prop, + signature: `"${concreteShapeType}"`, + } + : prop + ), + }) + } + continue + } + + shapes.push({ + name: displayName, + shapeType, + signature, + description: jsdoc.description, + propsType: `${displayName}Props`, + propsDescription: jsdoc.description, + props, + }) + } + + return { + shapeTypes: allShapeTypes, + shapes, + } +} + +// --- Generate METHOD_MAP --- + +type ArgKind = + | 'id' + | 'id-or-shape' + | 'ids-or-shapes' + | 'spread-ids' + | 'shape-partial' + | 'shape-partials' + | 'update-partial' + | 'update-partials' +type RetKind = + | 'this' + | 'shape' + | 'shape-or-null' + | 'shapes' + | 'id' + | 'id-or-null' + | 'ids' + | 'id-set' + +interface MethodMapEntry { + args: ArgKind[] + ret: RetKind +} + +function generateMethodMap( + editorClass: ts.ClassDeclaration, + context: DeclarationContext +): Record { + const map: Record = {} + + for (const member of editorClass.members) { + const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined + if ( + modifiers?.some( + (m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword + ) + ) + continue + + const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined + if (!name || name.startsWith('_')) continue + + const memberType = context.checker.getTypeAtLocation(member) + const signatures = memberType.getCallSignatures() + if (signatures.length === 0) continue + + const args: ArgKind[] = [] + let ret: RetKind | null = null + + for (const sig of signatures) { + for (let i = 0; i < sig.parameters.length; i++) { + const param = sig.parameters[i] + const paramType = context.checker.getTypeOfSymbolAtLocation(param, member) + const paramStr = context.checker.typeToString( + paramType, + member, + ts.TypeFormatFlags.NoTruncation + ) + const isRest = !!( + param.declarations?.[0] && + ts.isParameter(param.declarations[0]) && + param.declarations[0].dotDotDotToken + ) + + if (args[i]) continue + + const argKind = classifyParamType(paramStr, isRest) + if (argKind) { + while (args.length < i) args.push('id') + args[i] = argKind + } + } + + if (!ret) { + const retType = sig.getReturnType() + const retStr = context.checker.typeToString( + retType, + member, + ts.TypeFormatFlags.NoTruncation + ) + ret = classifyReturnType(retType, retStr, context.checker, member) + } + } + + if (args.length > 0 || ret) { + map[name] = { args, ret: ret ?? 'this' } + } + } + + return map +} + +function classifyParamType(typeStr: string, isRest: boolean): ArgKind | null { + if (typeStr.includes('TLCreateShapePartial')) { + return typeStr.includes('[]') ? 'shape-partials' : 'shape-partial' + } + if (typeStr.includes('TLShapePartial')) { + if (typeStr.includes('[]') || typeStr.includes('Array')) return 'update-partials' + return 'update-partial' + } + if (isRest && (typeStr.includes('TLShapeId') || typeStr.includes('TLShape'))) { + return 'spread-ids' + } + if ( + (typeStr.includes('TLShape[]') || typeStr.includes('TLShapeId[]')) && + typeStr.includes('[]') + ) { + return 'ids-or-shapes' + } + if ( + typeStr.includes('TLShape') || + typeStr.includes('TLShapeId') || + typeStr.includes('TLParentId') + ) { + return 'id-or-shape' + } + return null +} + +function classifyReturnType( + retType: ts.Type, + typeStr: string, + checker: ts.TypeChecker, + member: ts.ClassElement +): RetKind | null { + if (typeStr === 'this') return 'this' + + if (typeStr.includes('Set<') && typeStr.includes('TLShapeId')) return 'id-set' + + let resolvedStr = typeStr + if (retType.isTypeParameter()) { + const constraint = retType.getConstraint() + if (constraint) { + resolvedStr = checker.typeToString(constraint, member, ts.TypeFormatFlags.NoTruncation) + } + } + + if (retType.isUnion()) { + let hasShapeType = false + let hasShapeIdType = false + let hasNullish = false + for (const t of retType.types) { + let resolved = t + if (t.isTypeParameter()) { + const c = t.getConstraint() + if (c) resolved = c + } + const s = checker.typeToString(resolved) + if (s === 'undefined' || s === 'null') hasNullish = true + else if (s.includes('TLShapeId')) hasShapeIdType = true + else if (s.includes('Shape')) hasShapeType = true + } + if (hasShapeType && !hasShapeIdType) return 'shape-or-null' + if (hasShapeIdType && hasNullish) return 'id-or-null' + } + + if ( + resolvedStr.includes('TLShape') && + !resolvedStr.includes('TLShapeId') && + (resolvedStr.includes('[]') || typeStr.includes('[]')) + ) + return 'shapes' + + if (resolvedStr.includes('TLShapeId') && resolvedStr.includes('[]')) return 'ids' + + if (resolvedStr.includes('TLShape') && !resolvedStr.includes('TLShapeId')) { + if (/\bTLShape\b/.test(resolvedStr)) return 'shape-or-null' + } + + if ( + resolvedStr.includes('TLShapeId') && + (resolvedStr.includes('null') || resolvedStr.includes('undefined')) + ) { + return 'id-or-null' + } + + return null +} + +function writeMethodMap(map: Record) { + fs.writeFileSync(methodMapOutPath, JSON.stringify(map, null, 2)) +} + +// --- Post-processing: signature rewrites + example conversion --- + +/** + * Read a Record from an object literal in a source file, + * unwrapping `as const` if present. + */ +function readStringRecord(sourceFile: ts.SourceFile, varName: string): Record { + const entries: Record = {} + ts.forEachChild(sourceFile, (node) => { + if (!ts.isVariableStatement(node)) return + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || decl.name.text !== varName) continue + let init = decl.initializer + if (init && ts.isAsExpression(init)) init = init.expression + if (!init || !ts.isObjectLiteralExpression(init)) continue + for (const prop of init.properties) { + if (!ts.isPropertyAssignment(prop)) continue + const key = getPropertyName(prop.name) + if (!key) continue + entries[key] = prop.initializer.getText(sourceFile).replace(/['"]/g, '') + } + } + }) + return entries +} + +let GEO_TO_FOCUSED: Record = {} +let TLDRAW_TO_FOCUSED_FILL: Record = {} + +const INTERNAL_PROPS = new Set([ + 'typeName', + 'rotation', + 'index', + 'parentId', + 'opacity', + 'isLocked', + 'meta', + 'dash', + 'size', + 'font', + 'scale', + 'growY', + 'labelColor', + 'url', + 'verticalAlign', + 'autoSize', + 'fontSizeAdjustment', + 'elbowMidPoint', + 'labelPosition', + 'arrowheadEnd', + 'arrowheadStart', + 'spline', +]) + +function convertOldFormatExample(example: string): string { + if (!example.includes('props:') && !(example.includes('type:') && !example.includes('_type'))) { + return example + } + + try { + const wrapped = `const __ex = ${example.includes(';') ? `(() => { ${example} })()` : example}` + const sf = ts.createSourceFile( + 'example.ts', + wrapped, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS + ) + + let result = example + const replacements: Array<{ start: number; end: number; text: string }> = [] + + function visitNode(node: ts.Node) { + if (ts.isObjectLiteralExpression(node)) { + const converted = tryConvertShapeObject(node, sf) + if (converted) { + const prefixLen = wrapped.indexOf(example) + const start = node.getStart(sf) - prefixLen + const end = node.getEnd() - prefixLen + if (start >= 0 && end <= example.length) { + replacements.push({ start, end, text: converted }) + } + } + } + ts.forEachChild(node, visitNode) + } + + ts.forEachChild(sf, visitNode) + + replacements.sort((a, b) => b.start - a.start) + for (const rep of replacements) { + result = result.slice(0, rep.start) + rep.text + result.slice(rep.end) + } + return result + } catch { + return example + } +} + +function tryConvertShapeObject(node: ts.ObjectLiteralExpression, sf: ts.SourceFile): string | null { + const props = new Map() + let nestedProps: Map | null = null + let hasSpread = false + + for (const prop of node.properties) { + if (ts.isSpreadAssignment(prop)) { + hasSpread = true + continue + } + if (!ts.isPropertyAssignment(prop)) continue + const name = getPropertyName(prop.name) + if (!name) continue + + if (name === 'props' && ts.isObjectLiteralExpression(prop.initializer)) { + nestedProps = new Map() + for (const inner of prop.initializer.properties) { + if (!ts.isPropertyAssignment(inner)) continue + const innerName = getPropertyName(inner.name) + if (innerName) nestedProps.set(innerName, inner.initializer.getText(sf)) + } + } else { + props.set(name, prop.initializer.getText(sf)) + } + } + + const typeVal = props.get('type') + if (!typeVal) return null + const typeStr = typeVal.replace(/['"]/g, '') + + const shapeTypes = new Set(['geo', 'text', 'arrow', 'line', 'note', 'draw']) + if (!shapeTypes.has(typeStr)) return null + + if (hasSpread) return null + + const out: Array<[string, string]> = [] + + if (typeStr === 'geo' && nestedProps?.has('geo')) { + const geoVal = nestedProps.get('geo')!.replace(/['"]/g, '') + out.push(['_type', `'${GEO_TO_FOCUSED[geoVal] ?? geoVal}'`]) + nestedProps.delete('geo') + } else { + out.push(['_type', `'${typeStr}'`]) + } + + if (props.has('id')) { + let idVal = props.get('id')! + const match = idVal.match(/createShapeId\(\s*['"]([^'"]*)['"]\s*\)/) + if (match) idVal = `'${match[1]}'` + out.push(['shapeId', idVal]) + } + + for (const key of ['x', 'y']) { + if (props.has(key)) out.push([key, props.get(key)!]) + } + + if (nestedProps) { + for (const [key, val] of nestedProps) { + if (INTERNAL_PROPS.has(key)) continue + + if (key === 'richText') { + const rtMatch = val.match(/toRichText\(\s*(['"].*?['"])\s*\)/) + if (rtMatch) out.push(['text', rtMatch[1]]) + continue + } + + if (key === 'fill') { + const fillStr = val.replace(/['"]/g, '') + out.push(['fill', `'${TLDRAW_TO_FOCUSED_FILL[fillStr] ?? fillStr}'`]) + continue + } + + if (key === 'color') { + out.push(['color', val]) + continue + } + + out.push([key, val]) + } + } + + const handled = new Set(['type', 'id', 'x', 'y', 'props']) + for (const [key, val] of props) { + if (handled.has(key) || INTERNAL_PROPS.has(key)) continue + out.push([key, val]) + } + + return '{ ' + out.map(([k, v]) => `${k}: ${v}`).join(', ') + ' }' +} + +function rewriteSignature(sig: string): string { + return sig + .replace(/TLCreateShapePartial(<[^>]*>)?/g, 'TLShape') + .replace(/TLShapePartial(<[^>]*>)?/g, 'Partial') + .replace(/TLShapeId/g, 'string') + .replace(/TLParentId/g, 'string') +} + +function postProcessMembers(members: ExtractedMember[]): ExtractedMember[] { + return members.map((m) => ({ + ...m, + signature: rewriteSignature(m.signature), + examples: m.examples.map(convertOldFormatExample), + })) +} + +// --- Main --- + +function main() { + console.error( + `Extracting Editor API from:\n ${editorDtsPath}\n ${storeDtsPath}\n ${tlschemaDtsPath}\n ${formatTsPath}\n ${execHelpersPath}` + ) + fs.mkdirSync(distDir, { recursive: true }) + + // Read conversion maps from format.ts via AST + const formatSf = ts.createSourceFile( + formatTsPath, + fs.readFileSync(formatTsPath, 'utf-8'), + ts.ScriptTarget.Latest + ) + GEO_TO_FOCUSED = readStringRecord(formatSf, 'GEO_TO_FOCUSED_TYPES') + TLDRAW_TO_FOCUSED_FILL = readStringRecord(formatSf, 'SHAPE_TO_FOCUSED_FILLS') + + const members = extract() + const types = extractFocusedShapeTypes() + const exec = extractExecHelpers() + const categories = [...new Set(members.map((m) => m.category))].sort() + + // Generate METHOD_MAP + const editorContext = createDeclarationContext([editorDtsPath, storeDtsPath, tlschemaDtsPath]) + const editorSourceFile = editorContext.sourceFiles.get(editorDtsPath) + let editorClass: ts.ClassDeclaration | undefined + if (editorSourceFile) { + ts.forEachChild(editorSourceFile, (node) => { + if (ts.isClassDeclaration(node) && node.name?.text === 'Editor') { + editorClass = node + } + }) + } + if (editorClass) { + const methodMap = generateMethodMap(editorClass, editorContext) + writeMethodMap(methodMap) + console.error( + `Wrote ${Object.keys(methodMap).length} method map entries to dist/method-map.json` + ) + } + + const output = { + extractedAt: new Date().toISOString(), + memberCount: members.length, + categories, + members: postProcessMembers(members), + types, + helperCount: exec.helperCount, + helpers: exec.helpers, + } + + fs.writeFileSync(outPath, JSON.stringify(output, null, 2)) + console.error( + `Wrote ${members.length} members (${categories.length} categories), ${types.shapes.length} shape types, and ${exec.helperCount} exec helpers to dist/editor-api.json` + ) +} + +main() diff --git a/apps/mcp-app/server.ts b/apps/mcp-app/server.ts deleted file mode 100644 index 987c1dcd4822..000000000000 --- a/apps/mcp-app/server.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { TLShape } from 'tldraw' -import { registerTools } from './src/register-tools' -import { - MAX_CHECKPOINTS, - MCP_SERVER_DESCRIPTION, - MCP_SERVER_INSTRUCTIONS, - MCP_SERVER_NAME, - MCP_SERVER_TITLE, - MCP_SERVER_VERSION, - MCP_SERVER_WEBSITE_URL, -} from './src/shared/types' -import type { MCP_APP_HOST_NAMES, ServerDeps } from './src/shared/types' -import { resolveMcpAppHostNameFromServerInfo } from './src/shared/utils' -import { loadCachedCanvasWidgetHtml } from './src/tools/loadCachedCanvasWidgetHtml' - -// --- Server factory --- - -export function createServer() { - // Keep checkpoint state per server instance so parallel sessions do not bleed into each other. - const checkpoints = new Map< - string, - { shapes: TLShape[]; assets: unknown[]; bindings: unknown[] } - >() - let activeCheckpointId: string | null = null - const sessionId = crypto.randomUUID() - - function saveCheckpoint( - id: string, - shapes: TLShape[], - assets: unknown[] = [], - bindings: unknown[] = [] - ): void { - checkpoints.set(id, { shapes, assets, bindings }) - if (checkpoints.size > MAX_CHECKPOINTS) { - const oldest = checkpoints.keys().next().value - if (oldest) checkpoints.delete(oldest) - } - } - - function getActiveShapes(): TLShape[] { - if (!activeCheckpointId) { - if (checkpoints.size > 0) { - const lastKey = [...checkpoints.keys()].at(-1)! - const entry = checkpoints.get(lastKey)! - activeCheckpointId = lastKey - return entry.shapes - } - return [] - } - const entry = checkpoints.get(activeCheckpointId) - const shapes = entry?.shapes ?? [] - return shapes - } - - function getActiveAssets(): unknown[] { - if (!activeCheckpointId) return [] - const entry = checkpoints.get(activeCheckpointId) - return entry?.assets ?? [] - } - - function getActiveBindings(): unknown[] { - if (!activeCheckpointId) return [] - const entry = checkpoints.get(activeCheckpointId) - return entry?.bindings ?? [] - } - - let clientHostName: MCP_APP_HOST_NAMES | undefined - - const server = new McpServer( - { - name: MCP_SERVER_NAME, - version: MCP_SERVER_VERSION, - title: MCP_SERVER_TITLE, - description: MCP_SERVER_DESCRIPTION, - websiteUrl: MCP_SERVER_WEBSITE_URL, - }, - { - instructions: MCP_SERVER_INSTRUCTIONS, - } - ) - - server.server.oninitialized = () => { - const clientInfo = server.server.getClientVersion() - const resolved = resolveMcpAppHostNameFromServerInfo(clientInfo?.name ?? '') - if (resolved) clientHostName = resolved - } - - const deps: ServerDeps = { - saveCheckpoint, - loadCheckpoint(id: string) { - const entry = checkpoints.get(id) - if (!entry) return null - return { shapes: entry.shapes, assets: entry.assets, bindings: entry.bindings } - }, - getActiveShapes, - getActiveAssets, - getActiveBindings, - getActiveCheckpointId: () => activeCheckpointId, - setActiveCheckpointId: (id: string) => { - activeCheckpointId = id - }, - getSessionId: () => sessionId, - loadWidgetHtml: loadCachedCanvasWidgetHtml, - } - - registerTools(server, deps, { - isDev: true, - log: console.error, - getClientHostName: () => clientHostName, - }) - - return server -} diff --git a/apps/mcp-app/src/focused-shape-converters.ts b/apps/mcp-app/src/focused-shape-converters.ts deleted file mode 100644 index 022ce79c8e5c..000000000000 --- a/apps/mcp-app/src/focused-shape-converters.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { - toRichText, - type IndexKey, - type TLBindingCreate, - type TLParentId, - type TLShape, - type TLShapeId, -} from 'tldraw' -import { - FocusedColorSchema, - FocusedDashSchema, - FocusedFontSchema, - FocusedSizeSchema, - type FocusedColor, - type FocusedDash, - type FocusedFill, - type FocusedFont, - type FocusedGeoShapeType, - type FocusedShape, - type FocusedSize, - type FocusedTextAnchor, -} from './focused-shape-schema' - -// --- Mapping tables --- - -const FOCUSED_TO_GEO_TYPES: Record = { - rectangle: 'rectangle', - ellipse: 'ellipse', - triangle: 'triangle', - diamond: 'diamond', - hexagon: 'hexagon', - pill: 'oval', - cloud: 'cloud', - 'x-box': 'x-box', - 'check-box': 'check-box', - heart: 'heart', - pentagon: 'pentagon', - octagon: 'octagon', - star: 'star', - 'parallelogram-right': 'rhombus', - 'parallelogram-left': 'rhombus-2', - trapezoid: 'trapezoid', - 'fat-arrow-right': 'arrow-right', - 'fat-arrow-left': 'arrow-left', - 'fat-arrow-up': 'arrow-up', - 'fat-arrow-down': 'arrow-down', - geo: 'rectangle', -} - -const FOCUSED_TO_TLDRAW_FILLS: Record = { - none: 'none', - solid: 'lined-fill', - background: 'semi', - tint: 'solid', - pattern: 'pattern', -} - -const TLDRAW_TO_FOCUSED_FILLS: Record = { - none: 'none', - fill: 'solid', - 'lined-fill': 'solid', - semi: 'background', - solid: 'tint', - pattern: 'pattern', -} - -// Build the reverse mapping manually to avoid collisions. -// Multiple focused types map to the same tldraw geo (e.g. both 'rectangle' and 'geo' map to 'rectangle'). -// We want the reverse to prefer the specific name (e.g. 'rectangle' -> 'rectangle', not 'geo'). -const GEO_TO_FOCUSED_TYPES: Record = {} -for (const [focused, tldraw] of Object.entries(FOCUSED_TO_GEO_TYPES)) { - // Skip the generic 'geo' alias so specific names win - if (focused === 'geo') continue - GEO_TO_FOCUSED_TYPES[tldraw] = focused as FocusedGeoShapeType -} - -// --- Helpers --- - -function asColor(color: string): FocusedColor { - if (FocusedColorSchema.safeParse(color).success) return color as FocusedColor - if (color === 'pink' || color === 'light-pink') return 'light-violet' - return 'black' -} - -function toShapeId(id: string): TLShapeId { - return (id.startsWith('shape:') ? id : `shape:${id}`) as TLShapeId -} - -function toSimpleId(id: string): string { - return id.replace(/^shape:/, '') -} - -function fromRichText(richText: unknown): string { - if (!richText || typeof richText !== 'object') return '' - const rt = richText as { content?: unknown[] } - if (!Array.isArray(rt.content)) return '' - - const extractText = (node: unknown): string => { - if (!node || typeof node !== 'object') return '' - const maybeText = (node as { text?: unknown }).text - if (typeof maybeText === 'string') return maybeText - const children = (node as { content?: unknown }).content - if (!Array.isArray(children)) return '' - return children.map(extractText).join('') - } - - return rt.content.map(extractText).join('\n') -} - -function getMetaNote(record: TLShape): string { - const note = record.meta.note - return typeof note === 'string' ? note : '' -} - -function normalizeBox( - x: number, - y: number, - w: number, - h: number -): { x: number; y: number; w: number; h: number } { - let nextX = x - let nextY = y - let nextW = w - let nextH = h - - if (nextW < 0) { - nextX += nextW - nextW = Math.abs(nextW) - } - - if (nextH < 0) { - nextY += nextH - nextH = Math.abs(nextH) - } - - return { - x: nextX, - y: nextY, - w: Math.max(nextW, 1), - h: Math.max(nextH, 1), - } -} - -// --- Converters --- - -export function convertFocusedShapeToTldrawRecord(shape: FocusedShape): { - shape: TLShape - bindings: TLBindingCreate[] -} { - const base = { - typeName: 'shape' as const, - parentId: 'page:page' as TLParentId, - isLocked: false, - opacity: 1, - rotation: 0, - index: 'a1' as IndexKey, - meta: { - note: shape.note ?? '', - }, - } - - switch (shape._type) { - case 'text': { - let textAlign: 'start' | 'middle' | 'end' = 'start' - if (shape.anchor.includes('center')) textAlign = 'middle' - if (shape.anchor.includes('right')) textAlign = 'end' - - return { - shape: { - ...base, - id: toShapeId(shape.shapeId), - type: 'text', - x: shape.x, - y: shape.y, - props: { - richText: toRichText(shape.text), - color: asColor(shape.color), - size: shape.size ?? 'm', - font: shape.font ?? 'draw', - textAlign, - autoSize: shape.maxWidth == null, - w: shape.maxWidth ?? 100, - scale: 1, - }, - } as TLShape, - bindings: [], - } - } - case 'line': { - const minX = Math.min(shape.x1, shape.x2) - const minY = Math.min(shape.y1, shape.y2) - return { - shape: { - ...base, - id: toShapeId(shape.shapeId), - type: 'line', - x: minX, - y: minY, - props: { - color: asColor(shape.color), - dash: shape.dash ?? 'draw', - size: shape.size ?? 'm', - scale: 1, - spline: 'line', - points: { - a1: { - id: 'a1', - index: 'a1' as IndexKey, - x: shape.x1 - minX, - y: shape.y1 - minY, - }, - a2: { - id: 'a2', - index: 'a2' as IndexKey, - x: shape.x2 - minX, - y: shape.y2 - minY, - }, - }, - }, - } as TLShape, - bindings: [], - } - } - case 'arrow': { - const minX = Math.min(shape.x1, shape.x2) - const minY = Math.min(shape.y1, shape.y2) - const arrowShapeId = toShapeId(shape.shapeId) - const bindings: TLBindingCreate[] = [] - - if (shape.fromId) { - bindings.push({ - type: 'arrow', - fromId: arrowShapeId, - toId: toShapeId(shape.fromId), - props: { - terminal: 'start', - normalizedAnchor: { x: 0.5, y: 0.5 }, - isExact: false, - isPrecise: false, - }, - meta: {}, - }) - } - - if (shape.toId) { - bindings.push({ - type: 'arrow', - fromId: arrowShapeId, - toId: toShapeId(shape.toId), - props: { - terminal: 'end', - normalizedAnchor: { x: 0.5, y: 0.5 }, - isExact: false, - isPrecise: false, - }, - meta: {}, - }) - } - - return { - shape: { - ...base, - id: arrowShapeId, - type: 'arrow', - x: minX, - y: minY, - props: { - color: asColor(shape.color), - dash: shape.dash ?? 'draw', - size: shape.size ?? 'm', - fill: 'none', - font: 'draw', - arrowheadStart: 'none', - arrowheadEnd: 'arrow', - start: { x: shape.x1 - minX, y: shape.y1 - minY }, - end: { x: shape.x2 - minX, y: shape.y2 - minY }, - bend: (shape.bend ?? 0) * -1, - richText: toRichText(shape.text ?? ''), - labelColor: 'black', - labelPosition: 0.5, - scale: 1, - kind: 'arc', - elbowMidPoint: 0.5, - }, - } as TLShape, - bindings, - } - } - case 'note': { - return { - shape: { - ...base, - id: toShapeId(shape.shapeId), - type: 'note', - x: shape.x, - y: shape.y, - props: { - color: asColor(shape.color), - richText: toRichText(shape.text ?? ''), - size: shape.size ?? 'm', - font: shape.font ?? 'draw', - align: 'middle', - verticalAlign: 'middle', - fontSizeAdjustment: 1, - growY: 0, - labelColor: 'black', - scale: 1, - url: '', - textFirstEditedBy: null, - }, - } as TLShape, - bindings: [], - } - } - case 'draw': { - return { - shape: { - ...base, - id: toShapeId(shape.shapeId), - type: 'draw', - x: 0, - y: 0, - props: { - color: asColor(shape.color), - dash: 'draw', - size: 's', - fill: shape.fill ? (FOCUSED_TO_TLDRAW_FILLS[shape.fill] ?? 'none') : 'none', - segments: [], - isClosed: false, - isComplete: true, - isPen: false, - scale: 1, - scaleX: 1, - scaleY: 1, - }, - } as TLShape, - bindings: [], - } - } - case 'frame': { - const box = normalizeBox(shape.x, shape.y, shape.w, shape.h) - return { - shape: { - ...base, - id: toShapeId(shape.shapeId), - type: 'frame', - x: box.x, - y: box.y, - props: { - w: box.w, - h: box.h, - name: shape.name ?? '', - color: 'black', - }, - } as TLShape, - bindings: [], - } - } - case 'unknown': { - throw new Error( - `Cannot create unsupported shape type "${shape.subType}" from FocusedShape unknown.` - ) - } - default: { - const geoType = FOCUSED_TO_GEO_TYPES[shape._type] ?? 'rectangle' - const box = normalizeBox(shape.x, shape.y, shape.w, shape.h) - return { - shape: { - ...base, - id: toShapeId(shape.shapeId), - type: 'geo', - x: box.x, - y: box.y, - props: { - geo: geoType, - w: box.w, - h: box.h, - color: asColor(shape.color), - fill: FOCUSED_TO_TLDRAW_FILLS[shape.fill] ?? 'none', - dash: shape.dash ?? 'draw', - size: shape.size ?? 'm', - font: shape.font ?? 'draw', - align: shape.textAlign ?? 'middle', - verticalAlign: 'middle', - growY: 0, - richText: toRichText(shape.text ?? ''), - labelColor: 'black', - scale: 1, - url: '', - }, - } as TLShape, - bindings: [], - } - } - } -} - -/** - * Batch-convert focused shapes to tldraw records, resolving frame parent–child - * relationships so callers don't have to. - */ -export function convertFocusedShapesToTldrawRecords(shapes: FocusedShape[]): { - shapes: TLShape[] - bindings: TLBindingCreate[] -} { - const results = shapes.map((s) => convertFocusedShapeToTldrawRecord(s)) - const tldrawShapes = results.map((r) => r.shape) - const bindings = results.flatMap((r) => r.bindings) - - // Parent children of frames to their frame - const frames = shapes.filter((s) => s._type === 'frame') - for (const frame of frames) { - const frameId = toShapeId(frame.shapeId) - - if (frame.children?.length) { - // Explicit children list - for (const childId of frame.children) { - const child = tldrawShapes.find((r) => r.id === toShapeId(childId)) - if (child) { - child.parentId = frameId - } - } - } else { - // Fallback: parent shapes whose center falls within the frame bounds - const frameRecord = tldrawShapes.find((r) => r.id === frameId) - if (frameRecord) { - const fw = (frameRecord.props as any).w ?? 0 - const fh = (frameRecord.props as any).h ?? 0 - for (const record of tldrawShapes) { - if (record.id === frameId) continue - if ((record as any).parentId !== 'page:page') continue - const props = record.props as any - const sw = props.w ?? 0 - const sh = props.h ?? 0 - const cx = record.x + sw / 2 - const cy = record.y + sh / 2 - if (cx >= 0 && cy >= 0 && cx <= fw && cy <= fh) { - ;(record as any).parentId = frameId - } - } - } - } - } - - return { shapes: tldrawShapes, bindings } -} - -export function convertTldrawRecordToFocusedShape(record: TLShape): FocusedShape { - const simpleId = toSimpleId(record.id) - - switch (record.type) { - case 'geo': { - const { props } = record - return { - _type: GEO_TO_FOCUSED_TYPES[props.geo] ?? 'rectangle', - shapeId: simpleId, - x: record.x, - y: record.y, - w: props.w ?? 200, - h: props.h ?? 100, - color: asColor(props.color ?? 'black'), - fill: TLDRAW_TO_FOCUSED_FILLS[props.fill] ?? 'none', - dash: FocusedDashSchema.safeParse(props.dash).success - ? (props.dash as FocusedDash) - : 'draw', - size: FocusedSizeSchema.safeParse(props.size).success ? (props.size as FocusedSize) : 'm', - font: FocusedFontSchema.safeParse(props.font).success - ? (props.font as FocusedFont) - : 'draw', - text: fromRichText(props.richText) || undefined, - textAlign: (props.align as 'start' | 'middle' | 'end' | undefined) ?? 'middle', - note: getMetaNote(record), - } - } - case 'text': { - const { props } = record - const textAlign = props.textAlign ?? 'start' - const anchor: FocusedTextAnchor = - textAlign === 'middle' ? 'top-center' : textAlign === 'end' ? 'top-right' : 'top-left' - return { - _type: 'text', - shapeId: simpleId, - x: record.x, - y: record.y, - text: fromRichText(props.richText), - color: asColor(props.color ?? 'black'), - anchor, - size: FocusedSizeSchema.safeParse(props.size).success ? (props.size as FocusedSize) : 'm', - font: FocusedFontSchema.safeParse(props.font).success - ? (props.font as FocusedFont) - : 'draw', - maxWidth: props.autoSize ? null : (props.w ?? 100), - note: getMetaNote(record), - } - } - case 'arrow': { - const { props } = record - const start = props.start ?? { x: 0, y: 0 } - const end = props.end ?? { x: 0, y: 0 } - return { - _type: 'arrow', - shapeId: simpleId, - x1: record.x + start.x, - y1: record.y + start.y, - x2: record.x + end.x, - y2: record.y + end.y, - color: asColor(props.color ?? 'black'), - dash: FocusedDashSchema.safeParse(props.dash).success - ? (props.dash as FocusedDash) - : 'draw', - size: FocusedSizeSchema.safeParse(props.size).success ? (props.size as FocusedSize) : 'm', - text: fromRichText(props.richText) || undefined, - bend: props.bend ? props.bend * -1 : undefined, - fromId: null, - toId: null, - note: getMetaNote(record), - } - } - case 'line': { - const { props } = record - const a1 = props.points.a1 ?? { x: 0, y: 0 } - const a2 = props.points.a2 ?? { x: 0, y: 0 } - return { - _type: 'line', - shapeId: simpleId, - x1: record.x + (a1.x ?? 0), - y1: record.y + (a1.y ?? 0), - x2: record.x + (a2.x ?? 0), - y2: record.y + (a2.y ?? 0), - color: asColor(props.color ?? 'black'), - dash: FocusedDashSchema.safeParse(props.dash).success - ? (props.dash as FocusedDash) - : 'draw', - size: FocusedSizeSchema.safeParse(props.size).success ? (props.size as FocusedSize) : 'm', - note: getMetaNote(record), - } - } - case 'note': { - const { props } = record - return { - _type: 'note', - shapeId: simpleId, - x: record.x, - y: record.y, - color: asColor(props.color ?? 'yellow'), - size: FocusedSizeSchema.safeParse(props.size).success ? (props.size as FocusedSize) : 'm', - font: FocusedFontSchema.safeParse(props.font).success - ? (props.font as FocusedFont) - : 'draw', - text: fromRichText(props.richText) || undefined, - note: getMetaNote(record), - } - } - case 'draw': { - const { props } = record - return { - _type: 'draw', - shapeId: simpleId, - color: asColor(props.color ?? 'black'), - fill: TLDRAW_TO_FOCUSED_FILLS[props.fill] ?? 'none', - note: getMetaNote(record), - } - } - case 'frame': { - const { props } = record - return { - _type: 'frame', - shapeId: simpleId, - x: record.x, - y: record.y, - w: props.w ?? 500, - h: props.h ?? 300, - name: typeof props.name === 'string' ? props.name : undefined, - note: getMetaNote(record), - } - } - default: - return { - _type: 'unknown', - shapeId: simpleId, - subType: record.type, - x: record.x, - y: record.y, - note: getMetaNote(record), - } - } -} diff --git a/apps/mcp-app/src/focused-shape-schema.ts b/apps/mcp-app/src/focused-shape-schema.ts deleted file mode 100644 index c4a990f5a7d5..000000000000 --- a/apps/mcp-app/src/focused-shape-schema.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { z } from 'zod' - -const FocusedColorValueSchema = z.enum([ - 'red', - 'light-red', - 'green', - 'light-green', - 'blue', - 'light-blue', - 'orange', - 'yellow', - 'black', - 'violet', - 'light-violet', - 'grey', - 'white', -]) - -const FOCUSED_COLOR_ALIASES: Record> = { - 'light-orange': 'yellow', - brown: 'orange', - pink: 'light-violet', - purple: 'violet', - 'light-pink': 'light-violet', -} - -export const FocusedColorSchema = z.preprocess((value) => { - if (typeof value !== 'string') return value - const normalized = value.trim().toLowerCase() - return FOCUSED_COLOR_ALIASES[normalized] ?? normalized -}, FocusedColorValueSchema) - -export type FocusedColor = z.infer - -export const FocusedFillSchema = z.enum(['none', 'tint', 'background', 'solid', 'pattern']) -export type FocusedFill = z.infer - -export const FocusedSizeSchema = z.enum(['s', 'm', 'l', 'xl']) -export type FocusedSize = z.infer - -export const FocusedFontSchema = z.enum(['draw', 'sans', 'serif', 'mono']) -export type FocusedFont = z.infer - -export const FocusedDashSchema = z.enum(['draw', 'solid', 'dashed', 'dotted']) -export type FocusedDash = z.infer - -export const FocusedGeoShapeTypeSchema = z.enum([ - 'rectangle', - 'ellipse', - 'triangle', - 'diamond', - 'hexagon', - 'pill', - 'cloud', - 'x-box', - 'check-box', - 'heart', - 'pentagon', - 'octagon', - 'star', - 'parallelogram-right', - 'parallelogram-left', - 'trapezoid', - 'fat-arrow-right', - 'fat-arrow-left', - 'fat-arrow-up', - 'fat-arrow-down', - 'geo', -]) - -export type FocusedGeoShapeType = z.infer - -export const FocusedTextAnchorSchema = z.enum([ - 'bottom-center', - 'bottom-left', - 'bottom-right', - 'center-left', - 'center-right', - 'center', - 'top-center', - 'top-left', - 'top-right', -]) - -export type FocusedTextAnchor = z.infer - -export const FocusedGeoShapeSchema = z.object({ - _type: FocusedGeoShapeTypeSchema, - color: FocusedColorSchema, - dash: FocusedDashSchema.optional(), - fill: FocusedFillSchema, - font: FocusedFontSchema.optional(), - h: z.number(), - note: z.string().optional(), - shapeId: z.string(), - size: FocusedSizeSchema.optional(), - text: z.string().optional(), - textAlign: z.enum(['start', 'middle', 'end']).optional(), - w: z.number(), - x: z.number(), - y: z.number(), -}) - -export type FocusedGeoShape = z.infer - -export const FocusedTextShapeSchema = z.object({ - _type: z.literal('text'), - anchor: FocusedTextAnchorSchema, - color: FocusedColorSchema, - font: FocusedFontSchema.optional(), - fontSize: z.number().optional(), - maxWidth: z.number().nullable().optional(), - note: z.string().optional(), - shapeId: z.string(), - size: FocusedSizeSchema.optional(), - text: z.string(), - x: z.number(), - y: z.number(), -}) - -export type FocusedTextShape = z.infer - -export const FocusedArrowShapeSchema = z.object({ - _type: z.literal('arrow'), - bend: z.number().optional(), - color: FocusedColorSchema, - dash: FocusedDashSchema.optional(), - fromId: z.string().nullable().optional(), - note: z.string().optional(), - shapeId: z.string(), - size: FocusedSizeSchema.optional(), - text: z.string().optional(), - toId: z.string().nullable().optional(), - x1: z.number(), - x2: z.number(), - y1: z.number(), - y2: z.number(), -}) - -export type FocusedArrowShape = z.infer - -export const FocusedLineShapeSchema = z.object({ - _type: z.literal('line'), - color: FocusedColorSchema, - dash: FocusedDashSchema.optional(), - note: z.string().optional(), - shapeId: z.string(), - size: FocusedSizeSchema.optional(), - x1: z.number(), - x2: z.number(), - y1: z.number(), - y2: z.number(), -}) - -export type FocusedLineShape = z.infer - -export const FocusedNoteShapeSchema = z.object({ - _type: z.literal('note'), - color: FocusedColorSchema, - font: FocusedFontSchema.optional(), - note: z.string().optional(), - shapeId: z.string(), - size: FocusedSizeSchema.optional(), - text: z.string().optional(), - x: z.number(), - y: z.number(), -}) - -export type FocusedNoteShape = z.infer - -export const FocusedDrawShapeSchema = z.object({ - _type: z.literal('draw'), - color: FocusedColorSchema, - fill: FocusedFillSchema.optional(), - note: z.string().optional(), - shapeId: z.string(), -}) - -export type FocusedDrawShape = z.infer - -export const FocusedFrameShapeSchema = z.object({ - _type: z.literal('frame'), - children: z.array(z.string()).optional(), - h: z.number(), - name: z.string().optional(), - note: z.string().optional(), - shapeId: z.string(), - w: z.number(), - x: z.number(), - y: z.number(), -}) - -export type FocusedFrameShape = z.infer - -export const FocusedUnknownShapeSchema = z.object({ - _type: z.literal('unknown'), - note: z.string().optional(), - shapeId: z.string(), - subType: z.string(), - x: z.number(), - y: z.number(), -}) - -export type FocusedUnknownShape = z.infer - -const FOCUSED_SHAPES = [ - FocusedGeoShapeSchema, - FocusedTextShapeSchema, - FocusedArrowShapeSchema, - FocusedLineShapeSchema, - FocusedNoteShapeSchema, - FocusedDrawShapeSchema, - FocusedFrameShapeSchema, - FocusedUnknownShapeSchema, -] as const -export const FocusedShapeSchema = z.union(FOCUSED_SHAPES) - -export type FocusedShape = z.infer - -export const FocusedShapeTypeSchema = z.enum([ - ...FocusedGeoShapeTypeSchema.options, - 'text', - 'arrow', - 'line', - 'note', - 'draw', - 'frame', - 'unknown', -]) - -export const FocusedShapeUpdateSchema = z - .object({ - shapeId: z.string(), - _type: FocusedShapeTypeSchema.optional(), - anchor: FocusedTextAnchorSchema.optional(), - bend: z.number().optional(), - children: z.array(z.string()).optional(), - color: FocusedColorSchema.optional(), - dash: FocusedDashSchema.optional(), - fill: FocusedFillSchema.optional(), - font: FocusedFontSchema.optional(), - fontSize: z.number().optional(), - fromId: z.string().nullable().optional(), - h: z.number().optional(), - maxWidth: z.number().nullable().optional(), - name: z.string().optional(), - note: z.string().optional(), - size: FocusedSizeSchema.optional(), - subType: z.string().optional(), - text: z.string().optional(), - textAlign: z.enum(['start', 'middle', 'end']).optional(), - toId: z.string().nullable().optional(), - w: z.number().optional(), - x: z.number().optional(), - x1: z.number().optional(), - x2: z.number().optional(), - y: z.number().optional(), - y1: z.number().optional(), - y2: z.number().optional(), - }) - .loose() - -export type FocusedShapeUpdate = z.infer diff --git a/apps/mcp-app/src/parse-json.ts b/apps/mcp-app/src/parse-json.ts deleted file mode 100644 index c63ba61e2f09..000000000000 --- a/apps/mcp-app/src/parse-json.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - FocusedShapeSchema, - FocusedShapeUpdateSchema, - type FocusedShape, - type FocusedShapeUpdate, -} from './focused-shape-schema' - -function tryParseArray(json: string): unknown[] | null { - try { - const parsed = JSON.parse(json) - return Array.isArray(parsed) ? parsed : null - } catch { - return null - } -} - -function getNextNonWhitespace(input: string, startIndex: number): string | null { - for (let i = startIndex; i < input.length; i++) { - const char = input[i] - if (!/\s/.test(char)) return char - } - return null -} - -function getPrevNonWhitespace(output: string): string | null { - for (let i = output.length - 1; i >= 0; i--) { - const char = output[i] - if (!/\s/.test(char)) return char - } - return null -} - -export function healJsonArrayString(input: string): string { - // Normalize common typographic quotes first. - const normalized = input.replace(/[\u201C\u201D]/g, '"') - - let output = '' - let inString = false - let escaped = false - - for (let i = 0; i < normalized.length; i++) { - const char = normalized[i] - - if (inString) { - output += char - if (escaped) { - escaped = false - } else if (char === '\\') { - escaped = true - } else if (char === '"') { - inString = false - } - continue - } - - if (char === '"') { - // Heal the recurring model mistake: stray quote after a number, e.g. `"x": 100",`. - const prev = getPrevNonWhitespace(output) - const next = getNextNonWhitespace(normalized, i + 1) - if (prev && /[0-9]/.test(prev) && (next === ',' || next === '}' || next === ']')) { - continue - } - inString = true - output += char - continue - } - - // Heal trailing commas outside strings, e.g. `[{"x":1},]`. - if (char === ',') { - const next = getNextNonWhitespace(normalized, i + 1) - if (next === ']' || next === '}') { - continue - } - } - - output += char - } - - return output -} - -export function parseJsonArray(json: string, fieldName: string): unknown[] { - const parsed = tryParseArray(json) - if (parsed) return parsed - - const healed = healJsonArrayString(json) - const repaired = tryParseArray(healed) - if (repaired) return repaired - - throw new Error( - `${fieldName} must be a JSON array string. Build an array first, then pass JSON.stringify(array).` - ) -} - -export function parseFocusedShapesInput(json: string): FocusedShape[] { - const parsed = parseJsonArray(json, 'shapesJson') - const normalized: FocusedShape[] = [] - for (const input of parsed) { - const result = FocusedShapeSchema.safeParse(input) - if (!result.success) { - throw new Error(result.error.issues[0]?.message ?? 'Invalid shape in shapesJson') - } - normalized.push(result.data) - } - return normalized -} - -export function parseFocusedShapeUpdatesInput(json: string): FocusedShapeUpdate[] { - const parsed = parseJsonArray(json, 'updatesJson') - - const normalized: FocusedShapeUpdate[] = [] - for (const input of parsed) { - const result = FocusedShapeUpdateSchema.safeParse(input) - if (!result.success) { - throw new Error(result.error.issues[0]?.message ?? 'Invalid shape update in updatesJson') - } - normalized.push(result.data) - } - return normalized -} - -export function parseShapeIdsInput(json: string): string[] { - const parsed = parseJsonArray(json, 'shapeIdsJson') - return parsed.filter((id): id is string => typeof id === 'string') -} - -export function parseBooleanFlag(value: unknown, defaultValue = false): boolean { - if (typeof value === 'boolean') return value - if (typeof value === 'number') return value !== 0 - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase() - if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true - if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false - } - return defaultValue -} diff --git a/apps/mcp-app/src/register-tools.ts b/apps/mcp-app/src/register-tools.ts index 2eb34b89c049..a603db267399 100644 --- a/apps/mcp-app/src/register-tools.ts +++ b/apps/mcp-app/src/register-tools.ts @@ -1,51 +1,22 @@ -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from '@modelcontextprotocol/ext-apps/server' +import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' -import { structuredClone } from 'tldraw' -import type { TLShape } from 'tldraw' import { z } from 'zod' -import { - convertFocusedShapesToTldrawRecords, - convertFocusedShapeToTldrawRecord, - convertTldrawRecordToFocusedShape, -} from './focused-shape-converters' -import type { FocusedShape } from './focused-shape-schema' -import { - parseBooleanFlag, - parseFocusedShapesInput, - parseFocusedShapeUpdatesInput, - parseJsonArray, - parseShapeIdsInput, -} from './parse-json' -import { - type CreateShapesInput, - type DeleteShapesInput, - type UpdateShapesInput, - createShapesInputSchema, - deleteShapesInputSchema, - updateShapesInputSchema, -} from './shared/tool-schemas' import { CANVAS_RESOURCE_URI } from './shared/types' import type { RegisterToolsOptions, ServerDeps } from './shared/types' -import { - deepMerge, - errorResponse, - generateCheckpointId, - normalizeShapeId, - parseTlShapes, - toSimpleShapeId, -} from './shared/utils' -import { READ_ME_CONTENT } from './tools/read-me' +import { errorResponse, parseTlShapes } from './shared/utils' +import { registerExecTool } from './tools/exec' +import { registerSearchTool } from './tools/search' /** - * Shared tool/resource registration logic for both Node.js and Cloudflare Workers entry points. + * Shared tool/resource registration logic for the MCP worker runtime. * - * Both `server.ts` (Node) and `src/worker.ts` (Workers) call `registerTools()` - * with platform-specific storage backends. + * Tools: + * - search: Query the Editor API spec (server-side) + * - exec: Execute JS against the live editor in the widget + * - read_checkpoint: Read checkpoint data (app-only) + * - save_checkpoint: Save checkpoint data (app-only) + * - tldraw-canvas: Interactive canvas widget resource */ // --- Helpers --- @@ -55,25 +26,21 @@ function injectBootstrapData(html: string, bootstrap: Record): typeof Buffer !== 'undefined' ? (s: string) => Buffer.from(s).toString('base64') : btoa const encoded = toBase64(JSON.stringify(bootstrap)) const bootstrapScript = `` - // Replace the LAST — the inlined JS bundle may contain as a string literal const lastIdx = html.lastIndexOf('') if (lastIdx === -1) return html return html.slice(0, lastIdx) + bootstrapScript + html.slice(lastIdx) } -/** - * Returns the widget domain for the given host, or `undefined` in dev mode. - * - * - ChatGPT: https://developers.openai.com/apps-sdk/build/mcp-server#widget-domains - * Set `_meta.ui.domain` on the widget resource template. This is required for app - * submission and must be unique per app. ChatGPT renders the widget under - * `.web-sandbox.oaiusercontent.com` - * - * - Claude: https://claude.com/docs/connectors/building/mcp-apps/cross-compatibility#domain-handling - * Compute the value by running: - * `node -e 'const yourServerUrl = "https://example.com/mcp"; console.log(require("crypto").createHash("sha256").update(yourServerUrl).digest("hex").slice(0,32) + ".claudemcpcontent.com")'` -" - */ +function parseArrayJson(json: string, fieldName: string): unknown[] { + const parsed = JSON.parse(json) + if (!Array.isArray(parsed)) { + throw new Error( + `${fieldName} must be a JSON array string. Build an array first, then pass JSON.stringify(array).` + ) + } + return parsed +} + async function getWidgetDomain( hostName: string | undefined, isDev: boolean, @@ -100,321 +67,124 @@ export function registerTools( opts: RegisterToolsOptions ): void { const log = opts.log ?? ((...args: unknown[]) => console.error(...args)) - const getBindingFromId = (binding: unknown): TLShape['id'] | null => { - if (!binding || typeof binding !== 'object') return null - const maybeFromId = (binding as { fromId?: unknown }).fromId - return typeof maybeFromId === 'string' ? (maybeFromId as TLShape['id']) : null - } - const analytics = opts.analytics - // --- read_me --- + // --- search (server-side spec query) --- - server.registerTool( - 'diagram_drawing_read_me', - { - description: - 'Use whenever you want to create a diagram or drawing. Gets the tldraw shape format reference. Call this FIRST before creating diagrams or drawing.', - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - destructiveHint: false, - }, + registerSearchTool(server, { + analytics, + log, + loader: opts.searchWorkerLoader, + loadSpec: deps.loadEditorApiSpec, + }) + + // --- exec (client-side code execution) --- + + let currentExecCanvasId: string | null = null + + registerExecTool(server, { + analytics, + log, + pendingRequests: opts.pendingRequests, + setCurrentExecCanvasId: (id) => { + currentExecCanvasId = id }, - async (): Promise => { - analytics?.writeDataPoint({ - blobs: ['tool_called', 'read_me'], - }) - return { - content: [{ type: 'text', text: READ_ME_CONTENT }], - } - } - ) + }) - // --- create_shapes --- + // --- _exec_callback (app-only: widget resolves pending exec via callServerTool) --- - registerAppTool( - server, - 'create_shapes', + const execCallbackSchema = z.object({ + channel: z.string(), + result: z + .object({ + success: z.boolean(), + result: z.unknown().optional(), + error: z.string().optional(), + }) + .optional(), + error: z.string().optional(), + }) + + server.registerTool( + '_exec_callback', { - title: 'Create Shapes', - description: 'Creates shapes, drawings, and diagrams on the tldraw canvas.', - inputSchema: createShapesInputSchema, + title: 'Exec Callback', + description: 'App-only: widget calls this to resolve a pending exec request.', + inputSchema: execCallbackSchema, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, - _meta: { ui: { resourceUri: CANVAS_RESOURCE_URI } }, + _meta: { ui: { visibility: ['app'] } }, }, - async ({ shapesJson, new_blank_canvas }: CreateShapesInput): Promise => { - try { - log( - `[tldraw-mcp] create_shapes called: new_blank_canvas=${new_blank_canvas}, activeCheckpointId=${deps.getActiveCheckpointId()}` - ) - analytics?.writeDataPoint({ - blobs: ['tool_called', 'create_shapes'], - }) - const newBlankCanvas = parseBooleanFlag(new_blank_canvas, false) - const focusedShapes = parseFocusedShapesInput(shapesJson) - const { shapes: newRecords, bindings: newBindings } = - convertFocusedShapesToTldrawRecords(focusedShapes) - - const hadActiveCheckpoint = deps.getActiveCheckpointId() !== null - const baseShapes = newBlankCanvas ? [] : deps.getActiveShapes() - log( - `[tldraw-mcp] create_shapes: baseShapes=${baseShapes.length}, newRecords=${newRecords.length}, newBlankCanvas=${newBlankCanvas}, hadActiveCheckpoint=${hadActiveCheckpoint}` - ) - const mergedById = new Map() - for (const s of baseShapes) mergedById.set(s.id, structuredClone(s)) - for (const s of newRecords) mergedById.set(s.id, structuredClone(s)) - const resultShapes = [...mergedById.values()] - const existingAssets = newBlankCanvas ? [] : deps.getActiveAssets() - const existingBindings = newBlankCanvas ? [] : deps.getActiveBindings() - const replacedShapeIds = new Set(newRecords.map((shape) => shape.id)) - const preservedBindings = existingBindings.filter((binding) => { - const fromId = getBindingFromId(binding) - return fromId === null || !replacedShapeIds.has(fromId) - }) - const resultBindings = [...preservedBindings, ...newBindings] - - const checkpointId = generateCheckpointId() - deps.saveCheckpoint(checkpointId, resultShapes, existingAssets, resultBindings) - deps.setActiveCheckpointId(checkpointId) - - return { - content: [ - { - type: 'text', - text: newBlankCanvas - ? `Created ${focusedShapes.length} shape(s) on a new blank canvas.` - : `Created ${focusedShapes.length} shape(s).`, - }, - { type: 'text', text: JSON.stringify(focusedShapes, null, 2) }, - ], - structuredContent: { - checkpointId, - sessionId: deps.getSessionId(), - action: 'create' as const, - newBlankCanvas, - hadBaseShapes: baseShapes.length > 0, - focusedShapes, - tldrawRecords: resultShapes, - bindings: resultBindings, - }, - } - } catch (err) { - return errorResponse( - 'create_shapes', - err, - 'Ensure shapesJson is a valid JSON array string of shapes objects (call diagram_drawing_read_me first for the format reference). ' - ) + async ({ + channel, + result, + error, + }: z.infer): Promise => { + const handled = error + ? opts.pendingRequests.reject(channel, error) + : opts.pendingRequests.resolve(channel, result) + + if (!handled) { + log(`[tldraw-mcp] Ignoring exec callback for non-pending channel "${channel}"`) + return { content: [{ type: 'text', text: JSON.stringify({ ok: false }) }] } } + + const canvasId = currentExecCanvasId + currentExecCanvasId = null + return { content: [{ type: 'text', text: JSON.stringify({ ok: true, canvasId }) }] } } ) - // --- update_shapes --- + // --- _get_canvas_state (app-only: widget fetches fork data by canvasId) --- - registerAppTool( - server, - 'update_shapes', + server.registerTool( + '_get_canvas_state', { - title: 'Update Shapes', - description: 'Updates existing shapes, diagrams, and drawings on the tldraw canvas.', - inputSchema: updateShapesInputSchema, + title: 'Get Canvas State', + description: 'App-only: get the latest checkpoint for a canvas by its canvasId.', + inputSchema: z.object({ canvasId: z.string().min(1) }), annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, openWorldHint: false, }, - _meta: { ui: { resourceUri: CANVAS_RESOURCE_URI } }, + _meta: { ui: { visibility: ['app'] } }, }, - async ({ updatesJson }: UpdateShapesInput): Promise => { - try { - log(`[tldraw-mcp] update_shapes called: activeCheckpointId=${deps.getActiveCheckpointId()}`) - analytics?.writeDataPoint({ - blobs: ['tool_called', 'update_shapes'], - }) - const updates = parseFocusedShapeUpdatesInput(updatesJson) - const baseShapes = deps.getActiveShapes() - log( - `[tldraw-mcp] update_shapes: baseShapes=${baseShapes.length}, updates=${updates.length}` - ) - - if (baseShapes.length === 0) { - return errorResponse( - 'update_shapes', - new Error('No shapes on the canvas to update.'), - 'The canvas is empty. Use create_shapes first to add shapes, then update them.' - ) - } - - const shapesById = new Map(baseShapes.map((s) => [s.id, structuredClone(s)])) - const updated: string[] = [] - const notFound: string[] = [] - const failed: string[] = [] - const newBindings: unknown[] = [] - - for (const update of updates) { - const id = normalizeShapeId(update.shapeId) as TLShape['id'] - const existing = shapesById.get(id) - if (!existing) { - notFound.push(toSimpleShapeId(id)) - continue - } - - try { - const existingFocused = convertTldrawRecordToFocusedShape(existing) - const merged = deepMerge(existingFocused, { - ...update, - shapeId: toSimpleShapeId(id), - _type: update._type ?? existingFocused._type, - }) as FocusedShape - const result = convertFocusedShapeToTldrawRecord(merged) - result.shape.index = existing.index - result.shape.parentId = existing.parentId - shapesById.set(id, result.shape) - newBindings.push(...result.bindings) - updated.push(toSimpleShapeId(id)) - } catch { - failed.push(toSimpleShapeId(id)) - } - } - - const resultShapes = [...shapesById.values()] - const existingAssets = deps.getActiveAssets() - const existingBindings = deps.getActiveBindings() - const replacedShapeIds = new Set( - updated.map((shapeId) => normalizeShapeId(shapeId) as TLShape['id']) - ) - const preservedBindings = existingBindings.filter((binding) => { - const fromId = getBindingFromId(binding) - return fromId === null || !replacedShapeIds.has(fromId) - }) - const resultBindings = [...preservedBindings, ...newBindings] - - const checkpointId = generateCheckpointId() - deps.saveCheckpoint(checkpointId, resultShapes, existingAssets, resultBindings) - deps.setActiveCheckpointId(checkpointId) - - const lines = [`Updated ${updated.length} of ${updates.length} shape(s).`] - if (notFound.length > 0) { - const available = baseShapes.map((s) => toSimpleShapeId(s.id)) - lines.push( - `Skipped ${notFound.length} not found: ${notFound.join(', ')}. ` + - `Available shape IDs: ${available.join(', ')}` - ) - } - if (failed.length > 0) { - lines.push(`Skipped ${failed.length} due to invalid update data: ${failed.join(', ')}`) - } - + async ({ canvasId }: { canvasId: string }): Promise => { + const checkpointId = deps.getCanvasCheckpointId(canvasId) + if (!checkpointId) { return { content: [ - { - type: 'text', - text: lines.join('\n'), - }, + { type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) }, ], - structuredContent: { - checkpointId, - sessionId: deps.getSessionId(), - action: 'update' as const, - updates, - tldrawRecords: resultShapes, - bindings: resultBindings, - }, } - } catch (err) { - return errorResponse( - 'update_shapes', - err, - 'Ensure updatesJson is a valid JSON array of update objects, each with a shapeId field matching an existing shape on the canvas.' - ) } - } - ) - - // --- delete_shapes --- - - registerAppTool( - server, - 'delete_shapes', - { - title: 'Delete Shapes', - description: 'Deletes shapes by id from a JSON string (string[]).', - inputSchema: deleteShapesInputSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, - }, - _meta: { ui: { resourceUri: CANVAS_RESOURCE_URI } }, - }, - async ({ shapeIdsJson }: DeleteShapesInput): Promise => { - try { - log(`[tldraw-mcp] delete_shapes called: activeCheckpointId=${deps.getActiveCheckpointId()}`) - analytics?.writeDataPoint({ - blobs: ['tool_called', 'delete_shapes'], - }) - const shapeIds = parseShapeIdsInput(shapeIdsJson) - const baseShapes = deps.getActiveShapes() - log( - `[tldraw-mcp] delete_shapes: baseShapes=${baseShapes.length}, shapeIds=${shapeIds.length}` - ) - const idsToDelete = new Set(shapeIds.map((id) => normalizeShapeId(id))) - const resultShapes = baseShapes.filter((s) => !idsToDelete.has(s.id)) - const deletedCount = baseShapes.length - resultShapes.length - const existingAssets = deps.getActiveAssets() - // Filter out bindings that reference deleted shapes - const existingBindings = deps.getActiveBindings() - const resultBindings = existingBindings.filter((b: any) => { - return !idsToDelete.has(b.fromId) && !idsToDelete.has(b.toId) - }) - - const checkpointId = generateCheckpointId() - deps.saveCheckpoint(checkpointId, resultShapes, existingAssets, resultBindings) - deps.setActiveCheckpointId(checkpointId) - - const lines = [`Deleted ${deletedCount} of ${shapeIds.length} shape(s).`] - const notFoundCount = shapeIds.length - deletedCount - if (notFoundCount > 0) { - const notFoundIds = shapeIds.filter( - (id) => !baseShapes.some((s) => s.id === normalizeShapeId(id)) - ) - const available = baseShapes.map((s) => toSimpleShapeId(s.id)) - lines.push( - `${notFoundCount} ID(s) not found on canvas: ${notFoundIds.join(', ')}. ` + - `Available shape IDs: ${available.join(', ')}` - ) - } - + const checkpoint = deps.loadCheckpoint(checkpointId) + if (!checkpoint) { return { content: [ - { - type: 'text', - text: lines.join('\n'), - }, + { type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) }, ], - structuredContent: { - checkpointId, - sessionId: deps.getSessionId(), - action: 'delete' as const, - shapeIds, - tldrawRecords: resultShapes, - bindings: resultBindings, - }, } - } catch (err) { - return errorResponse( - 'delete_shapes', - err, - 'Ensure shapeIdsJson is a valid JSON array of shape ID strings, e.g. \'["box1", "arrow1"]\'.' - ) + } + const shapes = parseTlShapes(checkpoint.shapes) + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + checkpointId, + shapes, + assets: checkpoint.assets, + bindings: checkpoint.bindings, + }), + }, + ], } } ) @@ -445,7 +215,6 @@ export function registerTools( shapes: [], assets: [], bindings: [], - focusedShapes: [], }, } } @@ -453,44 +222,6 @@ export function registerTools( const shapes = parseTlShapes(checkpoint.shapes) const assets = checkpoint.assets const bindings = checkpoint.bindings - const arrowConnections = new Map() - for (const binding of bindings) { - if (!binding || typeof binding !== 'object') continue - const maybeFromId = (binding as { fromId?: unknown }).fromId - const maybeToId = (binding as { toId?: unknown }).toId - const terminal = (binding as { props?: { terminal?: unknown } }).props?.terminal - if ( - typeof maybeFromId !== 'string' || - typeof maybeToId !== 'string' || - (terminal !== 'start' && terminal !== 'end') - ) { - continue - } - - const simpleArrowId = toSimpleShapeId(maybeFromId as TLShape['id']) - const simpleTargetId = toSimpleShapeId(maybeToId as TLShape['id']) - const existing = arrowConnections.get(simpleArrowId) ?? { fromId: null, toId: null } - if (terminal === 'start') existing.fromId = simpleTargetId - if (terminal === 'end') existing.toId = simpleTargetId - arrowConnections.set(simpleArrowId, existing) - } - const focusedShapes = shapes - .map((s) => { - try { - const focused = convertTldrawRecordToFocusedShape(s) - if (focused._type === 'arrow') { - const connected = arrowConnections.get(focused.shapeId) - if (connected) { - focused.fromId = connected.fromId - focused.toId = connected.toId - } - } - return focused - } catch { - return null - } - }) - .filter(Boolean) return { content: [{ type: 'text', text: `${shapes.length} shape(s), ${assets.length} asset(s).` }], @@ -499,7 +230,6 @@ export function registerTools( shapes, assets, bindings, - focusedShapes, }, } } @@ -518,6 +248,7 @@ export function registerTools( shapesJson: z.string(), assetsJson: z.string().optional(), bindingsJson: z.string().optional(), + canvasId: z.string().optional(), }), annotations: { readOnlyHint: false, @@ -532,24 +263,29 @@ export function registerTools( shapesJson, assetsJson, bindingsJson, + canvasId, }: { checkpointId: string shapesJson: string assetsJson?: string bindingsJson?: string + canvasId?: string }): Promise => { try { log( - `[tldraw-mcp] save_checkpoint called: checkpointId=${checkpointId}, prev activeCheckpointId=${deps.getActiveCheckpointId()}` + `[tldraw-mcp] save_checkpoint called: checkpointId=${checkpointId}, canvasId=${canvasId ?? 'none'}, prev activeCheckpointId=${deps.getActiveCheckpointId()}` ) - const raw = parseJsonArray(shapesJson, 'shapesJson') + const raw = parseArrayJson(shapesJson, 'shapesJson') const shapes = parseTlShapes(raw) - const assets = assetsJson ? parseJsonArray(assetsJson, 'assetsJson') : [] - const bindings = bindingsJson ? parseJsonArray(bindingsJson, 'bindingsJson') : [] + const assets = assetsJson ? parseArrayJson(assetsJson, 'assetsJson') : [] + const bindings = bindingsJson ? parseArrayJson(bindingsJson, 'bindingsJson') : [] deps.saveCheckpoint(checkpointId, shapes, assets, bindings) deps.setActiveCheckpointId(checkpointId) + if (canvasId) { + deps.setCanvasCheckpointId(canvasId, checkpointId) + } log( - `[tldraw-mcp] save_checkpoint done: activeCheckpointId=${deps.getActiveCheckpointId()}, shapes=${shapes.length}, assets=${assets.length}` + `[tldraw-mcp] save_checkpoint done: activeCheckpointId=${deps.getActiveCheckpointId()}, canvasId=${canvasId ?? 'none'}, shapes=${shapes.length}, assets=${assets.length}` ) return { content: [ @@ -576,7 +312,7 @@ export function registerTools( CANVAS_RESOURCE_URI, { title: 'tldraw Canvas', - description: 'Interactive tldraw canvas UI used by create, update, and delete shape tools.', + description: 'Interactive tldraw canvas.', mimeType: RESOURCE_MIME_TYPE, }, async (): Promise => { @@ -585,22 +321,17 @@ export function registerTools( }) let html = await deps.loadWidgetHtml() - // Embed bootstrap data (session ID + active checkpoint) so the widget - // has shapes synchronously on mount — before any streaming begins. - const activeId = deps.getActiveCheckpointId() const sid = deps.getSessionId() const hostName = opts.getClientHostName() - const bootstrap: Record = { sessionId: sid, isDev: opts.isDev } - if (activeId) { - const checkpoint = deps.loadCheckpoint(activeId) - if (checkpoint) { - bootstrap.checkpointId = activeId - bootstrap.shapes = parseTlShapes(checkpoint.shapes) - bootstrap.assets = checkpoint.assets - bootstrap.bindings = checkpoint.bindings - } + const bootstrap: Record = { + sessionId: sid, + isDev: opts.isDev, + workerOrigin: opts.workerOrigin, + mcpSessionId: deps.getMcpSessionId(), + methodMap: await deps.loadMethodMap(), } + html = injectBootstrapData(html, bootstrap) const domain = await getWidgetDomain(hostName, opts.isDev, opts.workerOrigin) @@ -623,7 +354,7 @@ export function registerTools( ...(opts.extraResourceDomains ?? []), 'blob:', ], - connectDomains: ['https://cdn.tldraw.com', ...(opts.extraConnectDomains ?? [])], + connectDomains: opts.extraConnectDomains ?? [], }, permissions: { clipboardWrite: {} }, ...(domain ? { domain } : {}), diff --git a/apps/mcp-app/src/shared/generated-data.ts b/apps/mcp-app/src/shared/generated-data.ts new file mode 100644 index 000000000000..b2e17a1578aa --- /dev/null +++ b/apps/mcp-app/src/shared/generated-data.ts @@ -0,0 +1,160 @@ +export type ArgKind = + | 'id' + | 'id-or-shape' + | 'ids-or-shapes' + | 'spread-ids' + | 'shape-partial' + | 'shape-partials' + | 'update-partial' + | 'update-partials' + +export type RetKind = + | 'this' + | 'shape' + | 'shape-or-null' + | 'shapes' + | 'id' + | 'id-or-null' + | 'ids' + | 'id-set' + +export interface MethodSpec { + args: ArgKind[] + ret: RetKind +} + +export type MethodMap = Record + +export interface EditorApiSpec { + extractedAt: string + memberCount: number + categories: string[] + members: unknown[] + types: { + shapeTypes: string[] + shapes: unknown[] + } + helperCount: number + helpers: unknown[] +} + +interface AssetFetcher { + fetch(input: Request): Promise +} + +const GENERATED_ASSET_BASE_URL = 'https://assets.local/' + +let cachedEmbeddedMethodMap: MethodMap | null | undefined + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseMethodMap(value: unknown): MethodMap { + if (!isPlainObject(value)) { + throw new Error('Generated method map is missing or invalid.') + } + + const entries = Object.entries(value).map(([methodName, spec]) => { + if (!isPlainObject(spec) || !Array.isArray(spec.args) || typeof spec.ret !== 'string') { + throw new Error(`Generated method map entry "${methodName}" is invalid.`) + } + + return [ + methodName, + { + args: spec.args as ArgKind[], + ret: spec.ret as RetKind, + }, + ] as const + }) + + return Object.fromEntries(entries) +} + +function parseEditorApiSpec(value: unknown): EditorApiSpec { + if (!isPlainObject(value)) { + throw new Error('Generated editor API spec is missing or invalid.') + } + + if ( + !Array.isArray(value.categories) || + !Array.isArray(value.members) || + !isPlainObject(value.types) || + !Array.isArray(value.types.shapeTypes) || + !Array.isArray(value.types.shapes) || + !Array.isArray(value.helpers) + ) { + throw new Error('Generated editor API spec is malformed.') + } + + return { + extractedAt: typeof value.extractedAt === 'string' ? value.extractedAt : '', + memberCount: typeof value.memberCount === 'number' ? value.memberCount : value.members.length, + categories: value.categories.filter( + (category): category is string => typeof category === 'string' + ), + members: value.members, + types: { + shapeTypes: value.types.shapeTypes.filter( + (shapeType): shapeType is string => typeof shapeType === 'string' + ), + shapes: value.types.shapes, + }, + helperCount: typeof value.helperCount === 'number' ? value.helperCount : value.helpers.length, + helpers: value.helpers, + } +} + +async function loadGeneratedJsonFromAssets( + assets: AssetFetcher, + filename: string, + parser: (value: unknown) => T +): Promise { + const response = await assets.fetch(new Request(new URL(filename, GENERATED_ASSET_BASE_URL))) + if (!response.ok) { + throw new Error(`Failed to load generated asset "${filename}": ${response.status}`) + } + + return parser(await response.json()) +} + +function readMethodMapFromBootstrap(): MethodMap | null { + if (typeof window === 'undefined') return null + + const bootstrap = (window as Window & { __TLDRAW_BOOTSTRAP__?: unknown }).__TLDRAW_BOOTSTRAP__ + if (!isPlainObject(bootstrap) || !('methodMap' in bootstrap)) { + return null + } + + try { + return parseMethodMap(bootstrap.methodMap) + } catch { + return null + } +} + +export async function loadEditorApiSpecFromAssets(assets: AssetFetcher): Promise { + return loadGeneratedJsonFromAssets(assets, 'editor-api.json', parseEditorApiSpec) +} + +export async function loadMethodMapFromAssets(assets: AssetFetcher): Promise { + return loadGeneratedJsonFromAssets(assets, 'method-map.json', parseMethodMap) +} + +export function primeEmbeddedMethodMap(): void { + if (cachedEmbeddedMethodMap !== undefined) return + cachedEmbeddedMethodMap = readMethodMapFromBootstrap() +} + +export function getRequiredEmbeddedMethodMap(): MethodMap { + if (cachedEmbeddedMethodMap === undefined) { + cachedEmbeddedMethodMap = readMethodMapFromBootstrap() + } + + if (!cachedEmbeddedMethodMap) { + throw new Error('Missing embedded method map. Rebuild the widget assets and reload the app.') + } + + return cachedEmbeddedMethodMap +} diff --git a/apps/mcp-app/src/shared/pending-requests.ts b/apps/mcp-app/src/shared/pending-requests.ts new file mode 100644 index 000000000000..6ec49aa1e734 --- /dev/null +++ b/apps/mcp-app/src/shared/pending-requests.ts @@ -0,0 +1,69 @@ +/** + * Generic pending-request store for bridging async server↔widget communication. + * + * One pending request per named channel. The server creates a pending request + * and awaits it; the widget resolves or rejects it via `callServerTool('_exec_callback')`. + * + * Callbacks without an active pending request are ignored. This prevents late + * duplicate callbacks from being replayed into a later request on the same channel. + */ + +interface PendingEntry { + resolve(value: unknown): void + reject(reason: Error): void + timer: ReturnType +} + +export class PendingRequests { + private pending = new Map() + + /** + * Create a pending request for the given channel. + * Returns a promise that resolves when `resolve()` is called, + * or rejects on timeout or if `reject()` is called. + * + * Throws if a request is already pending for this channel. + */ + create(channel: string, timeoutMs = 30_000): Promise { + if (this.pending.has(channel)) { + throw new Error(`A request is already pending for channel "${channel}"`) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(channel) + reject(new Error(`Callback timed out after ${timeoutMs}ms for channel "${channel}"`)) + }, timeoutMs) + + this.pending.set(channel, { resolve, reject, timer }) + }) + } + + /** + * Resolve the pending request for the given channel. + * Returns false if no request is currently pending. + */ + resolve(channel: string, value: unknown): boolean { + const entry = this.pending.get(channel) + if (!entry) return false + + clearTimeout(entry.timer) + this.pending.delete(channel) + entry.resolve(value) + return true + } + + /** + * Reject the pending request for the given channel. + * Returns false if no request is currently pending. + */ + reject(channel: string, error: string): boolean { + const entry = this.pending.get(channel) + if (!entry) return false + + clearTimeout(entry.timer) + this.pending.delete(channel) + entry.reject(new Error(error)) + return true + } +} diff --git a/apps/mcp-app/src/shared/tool-schemas.ts b/apps/mcp-app/src/shared/tool-schemas.ts deleted file mode 100644 index f9481fe58d27..000000000000 --- a/apps/mcp-app/src/shared/tool-schemas.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod' - -export const createShapesInputSchema = z.object({ - new_blank_canvas: z - .boolean() - .optional() - .describe('If true, create_shapes starts from a new blank canvas. Defaults to false.'), - shapesJson: z - .string() - .describe('JSON array string of shapes. Must be a valid JSON array string.'), -}) - -export type CreateShapesInput = z.infer - -export const updateShapesInputSchema = z.object({ - updatesJson: z - .string() - .describe('JSON array string of shape updates. Must be a valid JSON array string.'), -}) - -export type UpdateShapesInput = z.infer - -export const deleteShapesInputSchema = z.object({ - shapeIdsJson: z - .string() - .describe( - 'JSON array string of shape ids to delete. Must be a valid JSON array string of shape ids.' - ), -}) - -export type DeleteShapesInput = z.infer diff --git a/apps/mcp-app/src/shared/types.ts b/apps/mcp-app/src/shared/types.ts index 18ec0924c89a..5d8468ae37fd 100644 --- a/apps/mcp-app/src/shared/types.ts +++ b/apps/mcp-app/src/shared/types.ts @@ -1,16 +1,27 @@ -import { DEFAULT_SUPPORTED_IMAGE_TYPES } from '@tldraw/utils' import type { TLShape } from 'tldraw' +import packageJson from '../../package.json' +import type { EditorApiSpec, MethodMap } from './generated-data' +import type { PendingRequests } from './pending-requests' + +export interface PendingBootstrap { + canvasId: string + checkpointId: string | null +} export interface ServerDeps { saveCheckpoint(id: string, shapes: TLShape[], assets?: unknown[], bindings?: unknown[]): void loadCheckpoint(id: string): { shapes: unknown[]; assets: unknown[]; bindings: unknown[] } | null - getActiveShapes(): TLShape[] - getActiveAssets(): unknown[] - getActiveBindings(): unknown[] getActiveCheckpointId(): string | null setActiveCheckpointId(id: string): void + getCanvasCheckpointId(canvasId: string): string | null + setCanvasCheckpointId(canvasId: string, checkpointId: string): void + setPendingBootstrap(bootstrap: PendingBootstrap): void + consumePendingBootstrap(): PendingBootstrap | null getSessionId(): string + getMcpSessionId(): string loadWidgetHtml(): Promise + loadEditorApiSpec(): Promise + loadMethodMap(): Promise } export interface RegisterToolsOptions { @@ -18,6 +29,8 @@ export interface RegisterToolsOptions { extraResourceDomains?: string[] /** Extra CSP connect domains. */ extraConnectDomains?: string[] + /** Dynamic Workers loader for sandboxed server-side code execution. */ + searchWorkerLoader: DynamicWorkerLoader /** Public origin of the deployed MCP worker, used for host-specific widget domains. */ workerOrigin?: string /** Flag so the tools, and thus the widget, know if they are running in dev mode. */ @@ -28,35 +41,35 @@ export interface RegisterToolsOptions { analytics?: AnalyticsEngineDataset /** Returns the resolved host name of the connected client. */ getClientHostName(): MCP_APP_HOST_NAMES | undefined + /** Pending requests store for widget→server callback bridge. */ + pendingRequests: PendingRequests +} + +export interface DynamicWorkerLoader { + load(code: { + compatibilityDate: string + mainModule: string + modules: Record + env?: unknown + globalOutbound?: null + }): { + getEntrypoint(name?: string): unknown + } } export const MCP_SERVER_NAME = 'tldraw' -export const MCP_SERVER_VERSION = '0.1.0' +export const MCP_SERVER_VERSION = packageJson.version export const MCP_SERVER_TITLE = 'tldraw Canvas' export const MCP_SERVER_DESCRIPTION = 'An interactive tldraw canvas with tools for diagramming, drawing, and more.' export const MCP_SERVER_WEBSITE_URL = 'https://www.tldraw.com' export const MCP_SERVER_INSTRUCTIONS = - 'Use diagram_drawing_read_me for shape format examples. For create_shapes, update_shapes, and delete_shapes, send JSON array strings (build the array first, then JSON.stringify). Use create_shapes before update_shapes or delete_shapes when the canvas is empty.' + 'Use `search` to query the tldraw Editor API spec (e.g. search for methods by category or name). Use `exec` to run JavaScript on the canvas — your code receives `editor` (the tldraw Editor instance) and helpers like toRichText, createShapeId, createArrowBetweenShapes. The current canvas state is kept in model context as raw TLShape, asset, and binding data.' export const CANVAS_RESOURCE_URI = 'ui://show-canvas/mcp-app.html' -const MIME_TO_EXT: Record = { - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/webp': 'webp', - 'image/gif': 'gif', - 'image/apng': 'apng', - 'image/avif': 'avif', - 'image/svg+xml': 'svg', -} - -export const ALLOWED_IMAGE_TYPES: Record = Object.fromEntries( - DEFAULT_SUPPORTED_IMAGE_TYPES.filter((mime) => mime in MIME_TO_EXT).map((mime) => [ - mime, - MIME_TO_EXT[mime], - ]) -) +/** Must match `compatibility_date` in wrangler.toml. */ +export const WORKER_COMPATIBILITY_DATE = '2025-03-10' export const MAX_CHECKPOINTS = 200 diff --git a/apps/mcp-app/src/shared/utils.ts b/apps/mcp-app/src/shared/utils.ts index 946d947dee16..c551ff6e45c5 100644 --- a/apps/mcp-app/src/shared/utils.ts +++ b/apps/mcp-app/src/shared/utils.ts @@ -2,25 +2,17 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' import type { TLShape } from 'tldraw' import type { MCP_APP_HOST_NAMES } from './types' -export function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function normalizeShapeId(id: string): string { - return id.startsWith('shape:') ? id : `shape:${id}` -} +const CANVAS_ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789' +const CANVAS_ID_LENGTH = 8 -export function toSimpleShapeId(id: string): string { - return id.replace(/^shape:/, '') +export function generateCanvasId(): string { + const bytes = new Uint8Array(CANVAS_ID_LENGTH) + crypto.getRandomValues(bytes) + return Array.from(bytes, (b) => CANVAS_ID_CHARS[b % CANVAS_ID_CHARS.length]).join('') } -export function deepMerge(base: unknown, patch: unknown): unknown { - if (!isPlainObject(base) || !isPlainObject(patch)) return patch - const merged: Record = { ...base } - for (const [key, value] of Object.entries(patch)) { - merged[key] = deepMerge(merged[key], value) - } - return merged +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) } export function parseTlShapes(value: unknown[]): TLShape[] { @@ -39,10 +31,6 @@ export function errorResponse(toolName: string, err: unknown, hint?: string): Ca } } -export function generateCheckpointId(): string { - return crypto.randomUUID().replace(/-/g, '').slice(0, 18) -} - // these are what we get from server.server.getClientVersion() in the worker export function resolveMcpAppHostNameFromServerInfo( potentialHostName: string diff --git a/apps/mcp-app/src/tools/exec.ts b/apps/mcp-app/src/tools/exec.ts new file mode 100644 index 000000000000..d2d74f08bc8e --- /dev/null +++ b/apps/mcp-app/src/tools/exec.ts @@ -0,0 +1,122 @@ +import { registerAppTool } from '@modelcontextprotocol/ext-apps/server' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' +import type { PendingRequests } from '../shared/pending-requests' +import { CANVAS_RESOURCE_URI } from '../shared/types' +import { generateCanvasId } from '../shared/utils' + +const EXEC_CALLBACK_TIMEOUT_MS = 30_000 + +export function registerExecTool( + server: McpServer, + opts: { + analytics?: AnalyticsEngineDataset + log(...args: unknown[]): void + pendingRequests: PendingRequests + setCurrentExecCanvasId(id: string): void + } +) { + registerAppTool( + server, + 'exec', + { + title: 'Execute Code', + description: `Execute JavaScript code on a tldraw canvas. The code runs in the widget with access to the live \`editor\` instance, helper functions, and normal js. Use the \`search\` tool first to discover available Editor methods and shape types. + +Each canvas has a unique \`canvasId\`. Omit \`canvasId\` to create a new blank canvas. To edit an existing canvas, pass the \`canvasId\` that was returned by a previous exec call. + +Shapes and text grow depending on the amount of text they have. Use clever scripting to ensure there are no unintended overlaps. + +Examples: +- Create a rectangle: editor.createShape({ _type: 'rectangle', shapeId: 'box1', x: 200, y: 120, w: 320, h: 180, text: 'Hello' }) +- Connect shapes with an arrow: editor.createShape({ _type: 'arrow', shapeId: 'a1', fromId: 'box1', toId: 'box2', x1: 0, y1: 0, x2: 100, y2: 0 }) +- Select and zoom: editor.select('box1'); editor.zoomToSelection() +- Read shapes: return editor.getCurrentPageShapes() +- Distribute evenly: editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal') +- Box around shapes: boxShapes(['box1', 'box2'], { text: 'Group label', color: 'blue' }) +- Stack shapes dynamically: editor.createShape({ _type: 'rectangle', shapeId: 'a', x: 0, y: 0, w: 300, h: 200, text: 'First box\\nwith wrapping text' }); const bounds = editor.getShapePageBounds('a'); editor.createShape({ _type: 'rectangle', shapeId: 'b', x: 0, y: bounds.maxY + 20, w: 300, h: 200, text: 'Below first' })`, + inputSchema: z.object({ + code: z + .string() + .describe( + 'JavaScript code to execute. Has access to `editor` (tldraw Editor instance) and helper functions.' + ), + canvasId: z + .string() + .optional() + .describe( + 'Canvas ID to edit. Omit to create a new blank canvas. Pass a canvasId from a previous exec result to continue editing that canvas.' + ), + }), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + _meta: { ui: { resourceUri: CANVAS_RESOURCE_URI } }, + }, + async ({ + code: _code, + canvasId: inputCanvasId, + }: { + code: string + canvasId?: string + }): Promise => { + opts.analytics?.writeDataPoint({ + blobs: ['tool_called', 'exec'], + }) + + const canvasId = inputCanvasId || generateCanvasId() + opts.setCurrentExecCanvasId(canvasId) + + opts.log(`[tldraw-mcp] exec called: canvasId=${canvasId}, existing=${Boolean(inputCanvasId)}`) + + try { + const result = (await opts.pendingRequests.create('exec', EXEC_CALLBACK_TIMEOUT_MS)) as { + success: boolean + result?: unknown + error?: string + } + + if (!result.success) { + opts.log(`[tldraw-mcp] exec failed: ${result.error}`) + return { + content: [ + { + type: 'text', + text: `Runtime error executing code on canvas. The code was NOT applied successfully. Fix the error and try again.\n\nCanvas ID: ${canvasId} — to retry on this canvas, pass this canvasId.\n\nError: ${result.error}`, + }, + ], + isError: true, + } + } + + const resultStr = + result.result !== undefined ? JSON.stringify(result.result, null, 2) : undefined + const lines = [ + resultStr + ? `Code executed successfully on canvas. Return value:\n${resultStr}` + : 'Code executed successfully on canvas.', + `\nCanvas ID: ${canvasId} — to edit this canvas again, pass this as the canvasId parameter.`, + ] + + opts.log(`[tldraw-mcp] exec succeeded, canvasId=${canvasId}`) + return { content: [{ type: 'text', text: lines.join('\n') }] } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + opts.log(`[tldraw-mcp] exec error: ${message}`) + return { + content: [ + { + type: 'text', + text: `Exec failed: ${message}`, + }, + ], + isError: true, + } + } + } + ) +} diff --git a/apps/mcp-app/src/tools/read-me.ts b/apps/mcp-app/src/tools/read-me.ts deleted file mode 100644 index 7dcf68313510..000000000000 --- a/apps/mcp-app/src/tools/read-me.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** read_me tool — returns the FocusedShape format and action tool documentation. */ - -/* eslint-disable no-useless-escape */ -export const READ_ME_CONTENT = `# tldraw MCP — shape format and action reference - -## Workflow - -1. Use \`create_shapes\` to create shapes on the canvas. -2. Optionally set \`new_blank_canvas: true\` on \`create_shapes\` to start from a blank canvas. -3. Use \`update_shapes\` to patch existing shapes. -4. Use \`delete_shapes\` to remove shapes. - -**Important** -- All shape mutation tools use JSON string arguments. -- Keep numeric fields as numbers in the underlying array objects (for example \`x: 100\`, not \`"100"\`) before stringifying. This is required, do not forget it. -- You will always be given the current state of the canvas in an attachment to a user's most recent message. Always refer to this when the user asks anything about the current canvas, when you want to edit the canvas, or when you need any information about it. -- Always output all of your changes of each type in just a single tool call. - -## FocusedShape format - -Shapes are JSON objects with a \`_type\` discriminator and unique \`shapeId\`. - -### Geo shape -\`\`\`json -{ "_type": "rectangle", "shapeId": "box1", "x": 100, "y": 100, "w": 200, "h": 120, "color": "blue", "fill": "tint", "dash": "draw", "size": "m", "font": "draw", "text": "Hello", "textAlign": "middle", "note": "optional note"} -\`\`\` - -### Text shape -\`\`\`json -{ "_type": "text", "shapeId": "label1", "x": 120, "y": 80, "text": "Hello World", "color": "black", "anchor": "top-left", "size": "m", "font": "draw", "maxWidth": null, "fontSize": 16, "note": "optional note" -} -\`\`\` - -### Arrow shape -\`\`\`json -{ "_type": "arrow", "shapeId": "arrow1", "x1": 300, "y1": 150, "x2": 500, "y2": 150, "color": "black", "dash": "draw", "size": "m", "text": "connects", "bend": 0, "fromId": "box1", "toId": "box2", "note": "optional note"} -\`\`\` - -### Line shape -\`\`\`json -{ "_type": "line", "shapeId": "line1", "x1": 100, "y1": 100, "x2": 300, "y2": 200, "color": "grey", "dash": "draw", "size": "m", "note": "optional note"} -\`\`\` - -### Note shape -\`\`\`json -{ "_type": "note", "shapeId": "note1", "x": 100, "y": 100, "color": "yellow", "text": "Remember this", "size": "m", "font": "draw", "note": "optional note"} -\`\`\` - -### Draw shape -\`\`\`json -{ "_type": "draw", "shapeId": "scribble1", "color": "blue", "fill": "none", "note": "optional note"} -\`\`\` - -### Frame shape -\`\`\`json -{ "_type": "frame", "shapeId": "backend", "x": 0, "y": 0, "w": 500, "h": 300, "name": "Backend Services", "children": ["api", "db", "cache"], "note": "optional note"} -\`\`\` - -### Enums -- \`_type\`: rectangle, ellipse, triangle, diamond, hexagon, pill, cloud, x-box, check-box, heart, pentagon, octagon, star, parallelogram-right, parallelogram-left, trapezoid, fat-arrow-right, fat-arrow-left, fat-arrow-up, fat-arrow-down, -- Colors: red, light-red, green, light-green, blue, light-blue, orange, yellow, black, violet, light-violet, grey, white -- Fill: none, tint, background, solid, pattern -- Dash: draw, solid, dashed, dotted -- Size: s, m, l, xl -- Font: draw, sans, serif, mono -- Text anchor: bottom-center, bottom-left, bottom-right, center-left, center-right, center, top-center, top-left, top-right -- Text align: start, middle, end - -## Available action tools - -### create_shapes -Creates one or more shapes from a JSON string. -- \`new_blank_canvas\` (optional boolean, default \`false\`): when \`true\`, clears the canvas before creating shapes -- \`shapesJson\`: JSON string of \`FocusedShape[]\` - -### update_shapes -Updates one or more existing shapes from a JSON string. -- \`updatesJson\`: JSON string of \`FocusedShapeUpdate[]\` -- Each update entry must include \`shapeId\` - -### delete_shapes -Deletes shapes by id from a JSON string. -- \`shapeIdsJson\`: JSON string of \`string[]\` - -## Example tool payloads - -### create_shapes -\`\`\`json -{ - "new_blank_canvas": true, - "shapesJson": "[{\"_type\":\"rectangle\",\"shapeId\":\"start\",\"x\":0,\"y\":0,\"w\":240,\"h\":120,\"color\":\"blue\",\"fill\":\"tint\",\"text\":\"Start\"},{\"_type\":\"rectangle\",\"shapeId\":\"process\",\"x\":520,\"y\":0,\"w\":280,\"h\":120,\"color\":\"green\",\"fill\":\"tint\",\"text\":\"Process\"},{\"_type\":\"arrow\",\"shapeId\":\"a1\",\"x1\":240,\"y1\":50,\"x2\":520,\"y2\":50,\"color\":\"black\",\"fromId\":\"start\",\"toId\":\"process\",\"text\":\"request\",\"bend\":-24},{\"_type\":\"arrow\",\"shapeId\":\"a2\",\"x1\":520,\"y1\":80,\"x2\":240,\"y2\":80,\"color\":\"grey\",\"fromId\":\"process\",\"toId\":\"start\",\"text\":\"result\",\"bend\":24}]" -} -\`\`\` - -### update_shapes -\`\`\`json -{ - "updatesJson": "[{\"shapeId\":\"process\",\"text\":\"Validate input\",\"color\":\"orange\"},{\"shapeId\":\"a1\",\"text\":\"next step\",\"bend\":-36},{\"shapeId\":\"a2\",\"text\":\"ack\",\"bend\":36}]" -} -\`\`\` - -### delete_shapes -\`\`\`json -{ - "shapeIdsJson": "[\"a1\",\"legacy_box\"]" -} -\`\`\` - -## Canvas coordinate space - -- The coordinate space is the same as on a website: 0,0 is the top left corner. The x-axis increases to the right. The y-axis increases downward. -- For most shapes, \`x\` and \`y\` define the top left corner of the shape. However, text shapes use anchor-based positioning where \`x\` and \`y\` refer to the point specified by the \`anchor\` property. - -## Tips for creating shapes - -- The main thing you must watch out for when creating diagrams is the overlap of shapes and text shapes and labels and arrow labels. -- Use the \`note\` field to provide context for each shape. This will help you understand the purpose of each shape later. -- Never create "unknown" type shapes. -- When creating shapes that are meant to be contained within other shapes, always ensure the inner shapes properly fit inside the containing shape. If there are overlaps, either make the inside shapes smaller or the outside shape bigger. -- Leave breathing room between neighboring shapes. As a default, target at least 140px horizontal gaps and at least 140px vertical gaps unless the user asks for a dense layout. - - If 2 shapes are connected via labeled arrows, give more space, especially horizontal space, and especially if the labels are long. - -## Arrows - -- When drawing arrows between shapes, include the shapes' ids as \`fromId\` and \`toId\` to bind them. -- Don't create duplicate arrows — check for existing arrows that already connect the same shapes. -- Make sure arrows are long enough to contain any labels you add to them. -- You can make an arrow curved using the \`bend\` property. The bend value (in pixels) determines how far the arrow's midpoint is displaced perpendicular to the straight line between its endpoints: - - A positive bend displaces the midpoint to the left of the arrow's direction - - A negative bend displaces the midpoint to the right of the arrow's direction - - Arrow going RIGHT: positive bend curves UP, negative curves DOWN - - Arrow going LEFT: positive bend curves DOWN, negative curves UP - - Arrow going DOWN: positive bend curves RIGHT, negative curves LEFT - - Arrow going UP: positive bend curves LEFT, negative curves RIGHT -- If 2 shapes are connected bidirectionally via arrows, you must give the arrows bends of at least 15 pixels of opposite signs so the labels do not overlap. - -## Text shapes - -- When creating a text shape, consider how much space the text will take up on the canvas. -- By default, the width of text shapes grows to fit the text content. -- The font size of a text shape is the height of the text. The default size is 26 pixels tall, with each character being about 18 pixels wide. -- The easiest way to make text fit within an area is to set the \`maxWidth\` property. The text will automatically wrap to fit within that width. -- Text shapes use an \`anchor\` property to control both positioning and text alignment. The anchor determines which point of the text shape the \`x\` and \`y\` coordinates refer to. - - For example, \`top-left\` means \`x\`,\`y\` is the top-left corner (left-aligned text). \`top-center\` means \`x\`,\`y\` is the top-center (center-aligned text). \`bottom-right\` means \`x\`,\`y\` is the bottom-right corner (right-aligned text). - - This makes it easy to position text relative to other shapes. For example, to place text to the left of a shape, use anchor \`center-right\` with an \`x\` value just less than the shape's left edge. - - This behavior is unique to text shapes. No other shape uses anchor-based positioning. -- Only ever use plain text, no special bullet points or anything like that. - -## Labels on shapes - -- Only add labels to shapes when the user asks for them or when the format clearly calls for labels (e.g. a "diagram" might have labels, but a "drawing" should not). -- When drawing a shape with a label, ensure the text fits inside. Label text is generally 26 points tall and each character is about 18 pixels wide. There are 32 pixels of padding around the text on each side. Factor this padding into your calculations. -- When a shape has a text label, it has a minimum height of 100, even if you set it smaller. -- If geo shapes or note shapes have text, the shapes will become taller to accommodate the text. If adding lots of text, make sure the shape is wide enough. -- Note shapes are 200x200. They're sticky notes suitable only for tiny sentences. Use a geo shape or text shape for longer text. -- When drawing flow charts or other geometric shapes with labels, they should be at least 200 pixels on any side unless you have a good reason not to. -- Prefer slightly larger defaults for readable diagrams: many labeled geo shapes should start around 220-280px wide and 120-160px tall, then scale up for longer labels. -- Compensate for different label lengths: short labels can use the lower end of the size range, while longer text should increase width (and sometimes height) to avoid cramped layouts. - -## Colors - -- When specifying a fill, you can use \`background\` to make the shape the same color as the canvas background (white in light mode, black in dark mode). -- When making shapes that should appear white (or black in dark mode), use \`background\` as the fill and \`grey\` as the color instead of \`white\`. This ensures there is a visible border around the shape. - -## Empty canvases - -- If a user asks for a new blank canvas, or asks you to pull up a canvas or board for them with no other context, call create_shapes with new_blank_canvas: true and an empty \`shapesJson\` array. - -## Diagram examples - -### Simple flowchart — two connected boxes - -\`\`\`json -[ - {"_type":"rectangle","shapeId":"start","x":0,"y":0,"w":260,"h":120,"color":"blue","fill":"tint","text":"Start"}, - {"_type":"rectangle","shapeId":"end","x":560,"y":0,"w":260,"h":120,"color":"green","fill":"tint","text":"End"}, - {"_type":"arrow","shapeId":"a1","x1":260,"y1":50,"x2":560,"y2":50,"color":"black","fromId":"start","toId":"end","text":"request","bend":-26}, - {"_type":"arrow","shapeId":"a2","x1":560,"y1":80,"x2":260,"y2":80,"color":"grey","fromId":"end","toId":"start","text":"response","bend":26} -] -\`\`\` - -### Decision flowchart — login flow with branching and retry loop - -\`\`\`json -[ - {"_type":"text","shapeId":"title","x":560,"y":0,"text":"Login flow","color":"black","anchor":"top-center","size":"xl","font":"sans"}, - {"_type":"pill","shapeId":"enter","x":420,"y":140,"w":280,"h":120,"color":"blue","fill":"tint","text":"User visits /login"}, - {"_type":"rectangle","shapeId":"form","x":420,"y":400,"w":280,"h":140,"color":"light-blue","fill":"tint","text":"Show login form"}, - {"_type":"arrow","shapeId":"a1","x1":560,"y1":260,"x2":560,"y2":400,"color":"black","fromId":"enter","toId":"form"}, - {"_type":"diamond","shapeId":"valid","x":380,"y":700,"w":360,"h":220,"color":"orange","fill":"tint","text":"Credentials\\nvalid?"}, - {"_type":"arrow","shapeId":"a2","x1":560,"y1":540,"x2":560,"y2":700,"color":"black","fromId":"form","toId":"valid","text":"submit","bend":52}, - {"_type":"arrow","shapeId":"a2b","x1":560,"y1":700,"x2":560,"y2":540,"color":"grey","dash":"dashed","fromId":"valid","toId":"form","text":"needs fix","bend":-52}, - {"_type":"rectangle","shapeId":"dashboard","x":980,"y":760,"w":300,"h":140,"color":"green","fill":"tint","text":"Redirect to\\ndashboard"}, - {"_type":"arrow","shapeId":"a3","x1":740,"y1":810,"x2":980,"y2":830,"color":"green","fromId":"valid","toId":"dashboard","text":"yes"}, - {"_type":"rectangle","shapeId":"error","x":-160,"y":760,"w":300,"h":140,"color":"red","fill":"tint","text":"Show error\\nmessage"}, - {"_type":"arrow","shapeId":"a4","x1":380,"y1":830,"x2":140,"y2":810,"color":"red","fromId":"valid","toId":"error","text":"no"} -] -\`\`\` - -Key techniques: -- Use \`pill\` for start/end nodes and \`diamond\` for decision points -- Bind arrows with \`fromId\`/\`toId\` so they stay connected when shapes move -- Leave at least ~140px between rows and columns to protect shape labels and arrow labels -- When two arrows connect the same two shapes, use opposite-sign \`bend\` values so labels do not collide -- Use \`dash: "dashed"\` for secondary/optional flows -- Center the title text using \`anchor: "top-center"\` - -### Architecture diagram — layered system with rectangles - -\`\`\`json -[ - {"_type":"text","shapeId":"title","x":820,"y":0,"text":"Web app architecture","color":"black","anchor":"top-center","size":"xl","font":"sans"}, - {"_type":"rectangle","shapeId":"fe-frame","x":0,"y":80,"w":1640,"h":260,"color":"blue","fill":"tint","text":"Frontend"}, - {"_type":"rectangle","shapeId":"browser","x":120,"y":150,"w":300,"h":140,"color":"blue","fill":"tint","text":"React SPA"}, - {"_type":"rectangle","shapeId":"cdn","x":640,"y":150,"w":260,"h":140,"color":"light-blue","fill":"tint","text":"CDN"}, - {"_type":"rectangle","shapeId":"lb","x":1120,"y":150,"w":320,"h":140,"color":"violet","fill":"tint","text":"Load balancer"}, - {"_type":"arrow","shapeId":"a1","x1":420,"y1":220,"x2":640,"y2":220,"color":"grey","fromId":"browser","toId":"cdn"}, - {"_type":"arrow","shapeId":"a2","x1":900,"y1":220,"x2":1120,"y2":220,"color":"grey","fromId":"cdn","toId":"lb"}, - {"_type":"rectangle","shapeId":"be-frame","x":0,"y":520,"w":1640,"h":260,"color":"green","fill":"tint","text":"Backend"}, - {"_type":"rectangle","shapeId":"api","x":120,"y":600,"w":300,"h":140,"color":"green","fill":"tint","text":"API server\\n(Node.js)"}, - {"_type":"rectangle","shapeId":"auth","x":640,"y":600,"w":260,"h":140,"color":"orange","fill":"tint","text":"Auth service"}, - {"_type":"cloud","shapeId":"queue","x":1120,"y":580,"w":320,"h":180,"color":"yellow","fill":"tint","text":"Message\\nqueue"}, - {"_type":"arrow","shapeId":"a3","x1":1280,"y1":340,"x2":270,"y2":600,"color":"black","fromId":"lb","toId":"api","text":"routes"}, - {"_type":"arrow","shapeId":"a4","x1":420,"y1":650,"x2":640,"y2":650,"color":"grey","fromId":"api","toId":"auth","text":"request","bend":-42}, - {"_type":"arrow","shapeId":"a4b","x1":640,"y1":700,"x2":420,"y2":700,"color":"grey","dash":"dashed","fromId":"auth","toId":"api","text":"response","bend":42}, - {"_type":"arrow","shapeId":"a5","x1":900,"y1":670,"x2":1120,"y2":670,"color":"grey","fromId":"auth","toId":"queue"}, - {"_type":"rectangle","shapeId":"db-frame","x":0,"y":960,"w":1640,"h":260,"color":"red","fill":"tint","text":"Data layer"}, - {"_type":"ellipse","shapeId":"db","x":120,"y":1040,"w":300,"h":140,"color":"red","fill":"tint","text":"PostgreSQL"}, - {"_type":"ellipse","shapeId":"cache","x":640,"y":1040,"w":260,"h":140,"color":"light-red","fill":"tint","text":"Redis"}, - {"_type":"rectangle","shapeId":"s3","x":1120,"y":1040,"w":320,"h":140,"color":"light-green","fill":"tint","text":"S3 storage"}, - {"_type":"arrow","shapeId":"a6","x1":270,"y1":740,"x2":270,"y2":1040,"color":"black","fromId":"api","toId":"db"}, - {"_type":"arrow","shapeId":"a7","x1":770,"y1":740,"x2":770,"y2":1040,"color":"black","fromId":"auth","toId":"cache"}, - {"_type":"arrow","shapeId":"a8","x1":1280,"y1":760,"x2":1280,"y2":1040,"color":"black","fromId":"queue","toId":"s3"} -] -\`\`\` - -Key techniques: -- Use \`rectangle\` shapes to visually group related components into layers -- Use different \`_type\` values to convey meaning: \`ellipse\` for databases, \`cloud\` for queues/services -- Assign a distinct color per layer (blue=frontend, green=backend, red=data) for quick visual parsing -- Keep generous spacing between neighboring columns and rows so labels and arrow text stay readable -- Use opposite-sign \`bend\` values on paired arrows (\`a4\` / \`a4b\`) to avoid overlapping labels -- Cross-layer arrows use \`"color":"black"\` to stand out; within-layer arrows use \`"color":"grey"\` -- Center the title over the diagram using \`anchor: "top-center"\` with an x value at the midpoint -` diff --git a/apps/mcp-app/src/tools/search.ts b/apps/mcp-app/src/tools/search.ts new file mode 100644 index 000000000000..29bd52048d93 --- /dev/null +++ b/apps/mcp-app/src/tools/search.ts @@ -0,0 +1,151 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' +import type { EditorApiSpec } from '../shared/generated-data' +import { WORKER_COMPATIBILITY_DATE } from '../shared/types' +import type { DynamicWorkerLoader } from '../shared/types' + +const SEARCH_TIMEOUT_MS = 5_000 +const SEARCH_RUNNER_MODULE = 'search-runner.js' +const SEARCH_RUNNER_ENTRYPOINT = 'SearchRunner' + +type SearchWorkerResult = { success: true; value: unknown } | { success: false; error: string } +type SearchSpec = Pick< + EditorApiSpec, + 'members' | 'categories' | 'types' | 'helperCount' | 'helpers' +> + +function createSearchRunnerModule(code: string) { + return `import { WorkerEntrypoint } from 'cloudflare:workers' + +function serializeResult(result) { + try { + return JSON.parse(JSON.stringify(result)) + } catch { + return String(result) + } +} + +export class ${SEARCH_RUNNER_ENTRYPOINT} extends WorkerEntrypoint { + async run() { + try { + const spec = this.env.SPEC + const result = await (async () => { +${code} + })() + return { success: true, value: serializeResult(result) } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + } + } + } +} +` +} + +async function runSearchInDynamicWorker( + loader: DynamicWorkerLoader, + spec: SearchSpec, + code: string +) { + const worker = loader.load({ + compatibilityDate: WORKER_COMPATIBILITY_DATE, + mainModule: SEARCH_RUNNER_MODULE, + modules: { + [SEARCH_RUNNER_MODULE]: { + js: createSearchRunnerModule(code), + }, + }, + env: { + SPEC: spec, + }, + globalOutbound: null, + }) + + const entrypoint = worker.getEntrypoint(SEARCH_RUNNER_ENTRYPOINT) as { + run(): Promise + } + const result = await Promise.race([ + entrypoint.run(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Search timed out after ${SEARCH_TIMEOUT_MS}ms`)), + SEARCH_TIMEOUT_MS + ) + ), + ]) + + if (!result.success) { + throw new Error(result.error) + } + + return result.value +} + +export function registerSearchTool( + server: McpServer, + opts: { + analytics?: AnalyticsEngineDataset + loader: DynamicWorkerLoader + loadSpec(): Promise + log(...args: unknown[]): void + } +) { + let specPromise: Promise | null = null + + server.registerTool( + 'search', + { + title: 'Search Editor API', + description: `Search the tldraw Editor API spec by writing JavaScript that receives a \`spec\` object and returns a result. The spec contains: spec.members (all Editor methods/properties with name, kind, signature, description, category), spec.categories (category names), spec.types.shapes (focused shape type definitions with props), spec.types.shapeTypes (list of all shape type strings), spec.helpers (exec helper functions with descriptions, params, examples). + +Examples: +- Find shape methods: return spec.members.filter(m => m.category === "shapes").map(m => ({ name: m.name, signature: m.signature })) +- Get arrow shape props: return spec.types.shapes.find(s => s.shapeType === "arrow") +- List all categories: return spec.categories +- Find a helper: return spec.helpers.find(h => h.name === "createArrowBetweenShapes")`, + inputSchema: z.object({ + code: z + .string() + .describe( + 'JavaScript code that receives `spec` and returns a result. Must use `return` to produce output.' + ), + }), + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + destructiveHint: false, + }, + }, + async ({ code }: { code: string }): Promise => { + opts.analytics?.writeDataPoint({ + blobs: ['tool_called', 'search'], + }) + opts.log('[tldraw-mcp] search called') + + try { + specPromise ??= opts.loadSpec().then((editorApi) => ({ + members: editorApi.members, + categories: editorApi.categories, + types: editorApi.types, + helperCount: editorApi.helperCount, + helpers: editorApi.helpers, + })) + const serialized = await runSearchInDynamicWorker(opts.loader, await specPromise, code) + + return { + content: [{ type: 'text', text: JSON.stringify(serialized, null, 2) }], + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + } + } + } + ) +} diff --git a/apps/mcp-app/src/widget/dev-log.tsx b/apps/mcp-app/src/widget/dev-log.tsx new file mode 100644 index 000000000000..59fa9b1674e0 --- /dev/null +++ b/apps/mcp-app/src/widget/dev-log.tsx @@ -0,0 +1,70 @@ +import { useCallback, useRef, useState } from 'react' + +const MAX_DEV_LOG_ENTRIES = 200 +export const DEV_LOG_PANEL_HEIGHT = 140 +export const DEV_LOG_PANEL_GAP = 8 + +export function useDevLog() { + const [isDev, setIsDev] = useState(false) + const [isDevLogVisible, setIsDevLogVisible] = useState(false) + const [devLogEntries, setDevLogEntries] = useState([]) + const isDevRef = useRef(false) + + const logIfDevMode = useCallback((message: string) => { + if (!isDevRef.current) return + setDevLogEntries((entries) => { + const timestamp = new Date().toLocaleTimeString() + const nextEntries = [...entries, `[${timestamp}] ${message}`] + return nextEntries.slice(-MAX_DEV_LOG_ENTRIES) + }) + }, []) + + const toggleDevLog = useCallback(() => { + setIsDevLogVisible((visible) => !visible) + }, []) + + const enableDevMode = useCallback(() => { + isDevRef.current = true + setIsDev(true) + setIsDevLogVisible(true) + }, []) + + return { + isDev, + isDevLogVisible, + devLogEntries, + isDevRef, + logIfDevMode, + toggleDevLog, + enableDevMode, + } +} + +export function DevLogPanel({ + entries, + isFullscreen, +}: { + entries: string[] + isFullscreen: boolean +}) { + return ( +
+ {entries.length > 0 ? entries.join('\n') : 'Dev log ready.'} +
+ ) +} diff --git a/apps/mcp-app/src/widget/exec-helpers.ts b/apps/mcp-app/src/widget/exec-helpers.ts new file mode 100644 index 000000000000..5e4c074e88fd --- /dev/null +++ b/apps/mcp-app/src/widget/exec-helpers.ts @@ -0,0 +1,232 @@ +import { + Box, + type Editor, + Mat, + TLShapeId, + Vec, + clamp, + createBindingId, + createShapeId, + degreesToRadians, + fitFrameToContent, + getArrowBindings, + radiansToDegrees, + toRichText, +} from 'tldraw' +import { getRequiredEmbeddedMethodMap } from '../shared/generated-data' +import { createFocusedEditorProxy } from './focused/focused-editor-proxy' + +function ensureTldrawShapeId(id: string): TLShapeId { + if (id.startsWith('shape:')) return id as TLShapeId + return ('shape:' + id) as TLShapeId +} + +function createArrowBetweenShapesFn(editor: Editor) { + /** + * Create an arrow shape that connects two existing shapes by their IDs. + * + * @param fromId - The shape ID to connect the arrow start to. + * @param toId - The shape ID to connect the arrow end to. + * @param opts - Optional arrow properties: a signed bend amount for the curve and a text label. + * + * + * @example + * createArrowBetweenShapes('box1', 'box2', { text: 'next', bend: 50 }) + */ + return (fromId: string, toId: string, opts?: { bend?: number; text?: string }) => { + const arrowId = createShapeId() + const resolvedFromId = ensureTldrawShapeId(fromId) + const resolvedToId = ensureTldrawShapeId(toId) + editor.createShape({ + id: arrowId, + type: 'arrow', + props: { + ...(opts?.text ? { richText: toRichText(opts.text) } : {}), + ...(opts?.bend != null ? { bend: opts.bend } : {}), + }, + }) + editor.createBindings([ + { + id: createBindingId(), + type: 'arrow', + fromId: arrowId, + toId: resolvedFromId, + props: { + terminal: 'start', + isPrecise: false, + isExact: false, + normalizedAnchor: { x: 0.5, y: 0.5 }, + }, + }, + { + id: createBindingId(), + type: 'arrow', + fromId: arrowId, + toId: resolvedToId, + props: { + terminal: 'end', + isPrecise: false, + isExact: false, + normalizedAnchor: { x: 0.5, y: 0.5 }, + }, + }, + ]) + return editor + } +} + +const BOX_SHAPES_MARGIN = 40 + +function boxShapesFn(editor: Editor) { + /** + * Create a rectangle shape around a group of existing shapes with a margin. Also groups the shapes together. + * + * @param shapesOrIds - Array of shape IDs or shape objects to box around. + * @param opts - Optional properties: shapeId, color, fill, text, note. + * + * @example + * boxShapes(['box1', 'box2'], { text: 'Group A', color: 'blue' }) + */ + return ( + shapesOrIds: (string | { shapeId: string })[], + opts?: { shapeId?: string; color?: string; fill?: string; text?: string; note?: string } + ) => { + const ids = shapesOrIds.map((s) => + typeof s === 'string' ? ensureTldrawShapeId(s) : ensureTldrawShapeId(s.shapeId) + ) + + const bounds = editor.getShapesPageBounds(ids) + if (!bounds) return editor + + const boxId = opts?.shapeId ? ensureTldrawShapeId(opts.shapeId) : createShapeId() + + editor.createShape({ + id: boxId, + type: 'geo', + x: bounds.x - BOX_SHAPES_MARGIN, + y: bounds.y - BOX_SHAPES_MARGIN, + props: { + geo: 'rectangle', + w: bounds.w + BOX_SHAPES_MARGIN * 2, + h: bounds.h + BOX_SHAPES_MARGIN * 2, + color: (opts?.color ?? 'black') as any, + fill: 'none' as any, + align: 'start' as any, + verticalAlign: 'start' as any, + ...(opts?.text ? { richText: toRichText(opts.text) } : {}), + }, + meta: { note: opts?.note ?? '' }, + }) + + editor.sendToBack([boxId]) + editor.groupShapes([...ids, boxId]) + + return editor + } +} + +export function createExecHelpers(editor: Editor) { + const helpers = { + createShapeId, + createBindingId, + Box, + Vec, + Mat, + clamp, + degreesToRadians, + radiansToDegrees, + getArrowBindings, + fitFrameToContent, + createArrowBetweenShapes: createArrowBetweenShapesFn(editor), + boxShapes: boxShapesFn(editor), + } + + return helpers +} + +export type ExecHelpers = ReturnType + +const EXEC_TIMEOUT_MS = 10_000 + +function serializeResult(result: unknown) { + try { + return JSON.parse(JSON.stringify(result)) + } catch { + return result != null ? String(result) : undefined + } +} + +async function loadExecModule(code: string) { + const moduleSource = `export default async function runExec({ editor, helpers }) { + const { + createShapeId, + createBindingId, + Box, + Vec, + Mat, + clamp, + degreesToRadians, + radiansToDegrees, + getArrowBindings, + fitFrameToContent, + createArrowBetweenShapes, + boxShapes, + } = helpers + + return await (async () => { +${code} + })() +}` + + const moduleUrl = URL.createObjectURL(new Blob([moduleSource], { type: 'text/javascript' })) + try { + return (await import(/* @vite-ignore */ moduleUrl)).default as (args: { + editor: Editor + helpers: ExecHelpers + }) => Promise + } finally { + URL.revokeObjectURL(moduleUrl) + } +} + +export async function executeCode( + editor: Editor, + code: string +): Promise<{ success: boolean; result?: unknown; error?: string }> { + const focusedEditor = createFocusedEditorProxy(editor, getRequiredEmbeddedMethodMap()) + const helpers = createExecHelpers(editor) + + const originalFetch = window.fetch + const originalXHR = window.XMLHttpRequest + const originalSetInterval = window.setInterval + const originalSetTimeout = window.setTimeout + + try { + // Disable fetch, XMLHttpRequest, and timers while the exec code runs + ;(window as any).fetch = undefined + ;(window as any).XMLHttpRequest = undefined + ;(window as any).setInterval = undefined + ;(window as any).setTimeout = undefined + + const runExec = await loadExecModule(code) + const result = await Promise.race([ + runExec({ editor: focusedEditor, helpers }), + new Promise((_, reject) => + originalSetTimeout( + () => reject(new Error(`Execution timed out after ${EXEC_TIMEOUT_MS}ms`)), + EXEC_TIMEOUT_MS + ) + ), + ]) + + return { success: true, result: serializeResult(result) } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { success: false, error: message } + } finally { + window.fetch = originalFetch + window.XMLHttpRequest = originalXHR + window.setInterval = originalSetInterval + window.setTimeout = originalSetTimeout + } +} diff --git a/apps/mcp-app/src/widget/focused/defaults.ts b/apps/mcp-app/src/widget/focused/defaults.ts new file mode 100644 index 000000000000..240c9aa0f14f --- /dev/null +++ b/apps/mcp-app/src/widget/focused/defaults.ts @@ -0,0 +1,141 @@ +/** + * Default shape templates for creating new shapes from focused format. + * Extracted from tldraw-internal desktop app handlers.ts. + */ +import { createShapeId, IndexKey, TLShape, toRichText } from 'tldraw' +import { FOCUSED_TO_GEO_TYPES, type FocusedShape } from './format' + +export function getDefaultShape(shapeType: FocusedShape['_type']): Partial { + const isGeo = shapeType in FOCUSED_TO_GEO_TYPES + if (isGeo) { + return { + isLocked: false, + opacity: 1, + rotation: 0, + meta: {}, + id: createShapeId(), + props: { + align: 'middle', + color: 'black', + dash: 'draw', + fill: 'none', + font: 'draw', + geo: 'rectangle', + growY: 0, + h: 200, + labelColor: 'black', + richText: toRichText(''), + scale: 1, + size: 'm', + url: '', + verticalAlign: 'middle', + w: 200, + }, + } as any + } + + switch (shapeType) { + case 'text': + return { + isLocked: false, + opacity: 1, + rotation: 0, + meta: {}, + id: createShapeId(), + props: { + autoSize: true, + color: 'black', + font: 'draw', + richText: toRichText(''), + scale: 1, + size: 'm', + textAlign: 'start', + w: 100, + }, + } as any + case 'line': + return { + isLocked: false, + opacity: 1, + rotation: 0, + meta: {}, + id: createShapeId(), + props: { + size: 'm', + color: 'black', + dash: 'draw', + points: { + a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 }, + a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 0 }, + }, + scale: 1, + spline: 'line', + }, + } as any + case 'arrow': + return { + isLocked: false, + opacity: 1, + rotation: 0, + meta: {}, + id: createShapeId(), + props: { + arrowheadEnd: 'arrow', + arrowheadStart: 'none', + bend: 0, + color: 'black', + dash: 'draw', + elbowMidPoint: 0.5, + end: { x: 100, y: 0 }, + fill: 'none', + font: 'draw', + kind: 'arc', + labelColor: 'black', + labelPosition: 0.5, + richText: toRichText(''), + scale: 1, + size: 'm', + start: { x: 0, y: 0 }, + }, + } as any + case 'note': + return { + isLocked: false, + opacity: 1, + rotation: 0, + meta: {}, + id: createShapeId(), + props: { + color: 'black', + richText: toRichText(''), + size: 'm', + align: 'middle', + font: 'draw', + fontSizeAdjustment: 0, + growY: 0, + labelColor: 'black', + scale: 1, + url: '', + verticalAlign: 'middle', + }, + } as any + case 'draw': + return { + isLocked: false, + opacity: 1, + rotation: 0, + meta: {}, + id: createShapeId(), + props: {}, + } as any + default: + return { + isLocked: false, + opacity: 1, + rotation: 0, + meta: {}, + id: createShapeId(), + props: {}, + } as any + } +} diff --git a/apps/mcp-app/src/widget/focused/focused-editor-proxy.ts b/apps/mcp-app/src/widget/focused/focused-editor-proxy.ts new file mode 100644 index 000000000000..008540fa4ac8 --- /dev/null +++ b/apps/mcp-app/src/widget/focused/focused-editor-proxy.ts @@ -0,0 +1,434 @@ +/** + * ES Proxy that wraps the tldraw Editor to auto-convert between focused format + * and tldraw's internal format at method boundaries. + * + * AI agents interact entirely in focused format — simple string IDs, flat shape + * objects with `_type` — and the proxy silently translates to/from tldraw's + * `TLShape`, `TLShapeId`, etc. + */ +import { + Editor, + TLBindingId, + TLCreateShapePartial, + TLShape, + TLShapeId, + TLShapePartial, +} from 'tldraw' +import type { MethodMap, RetKind } from '../../shared/generated-data' +import { getDefaultShape } from './defaults' +import { convertSimpleIdToTldrawId, convertTldrawIdToSimpleId, type FocusedShape } from './format' +import { convertTldrawShapeToFocusedShape } from './to-focused' +import { convertFocusedShapeToTldrawShape } from './to-tldraw' + +// --------------------------------------------------------------------------- +// Input conversion helpers +// --------------------------------------------------------------------------- + +/** Convert a single value that might be a focused ID or focused shape into tldraw format. */ +function convertIdOrShape(val: string | TLShapeId | TLShape | FocusedShape | null | undefined) { + if (val === null || val === undefined) return val + if (typeof val === 'string') return ensureTldrawId(val) + if ('_type' in val) { + return ensureTldrawId((val as FocusedShape).shapeId) + } + return val +} + +/** Convert an array where each element might be a focused ID or focused shape. */ +function convertIdsOrShapes( + arr: Array | TLShapeId[] | TLShape[] +) { + return arr.map(convertIdOrShape) +} + +/** Ensure a string is a valid TLShapeId. Passthrough if already prefixed. */ +function ensureTldrawId(id: string): TLShapeId { + if (id.startsWith('shape:')) return id as TLShapeId + return convertSimpleIdToTldrawId(id) +} + +/** Detect whether a value is a focused shape (has `_type` field). */ +function isFocusedShape( + val: FocusedShape | TLShapePartial | TLCreateShapePartial +): val is FocusedShape { + return '_type' in val +} + +/** Detect update payloads that use focused `shapeId` but omit `_type`. */ +function hasFocusedShapeId( + val: FocusedShape | TLShapePartial | TLCreateShapePartial +): val is TLShapePartial & { shapeId: string } { + return ( + typeof val === 'object' && + val !== null && + 'shapeId' in val && + typeof (val as { shapeId?: unknown }).shapeId === 'string' + ) +} + +/** Normalize raw tldraw shape partial IDs when models omit the `shape:` prefix. */ +function normalizeRawShapePartialId(partial: T): T { + const rawId = partial.id + if (typeof rawId !== 'string') return partial + if (rawId.startsWith('shape:')) return partial + return { ...partial, id: ensureTldrawId(rawId) } as T +} + +/** + * Normalize an incoming partial into focused shape format when possible. + * - If `_type` is already present, returns as-is. + * - If `shapeId` is present, infers `_type` from the existing canvas shape. + * - Otherwise returns null, meaning callers should treat it as raw tldraw input. + */ +function toFocusedShapeIfPossible( + editor: Editor, + partial: FocusedShape | TLShapePartial | TLCreateShapePartial +): FocusedShape | null { + if (isFocusedShape(partial)) return partial + if (!hasFocusedShapeId(partial)) return null + + const shapeId = ensureTldrawId(partial.shapeId) + const existingShape = editor.getShape(shapeId) + if (!existingShape) return null + + const focusedExisting = convertTldrawShapeToFocusedShape(editor, existingShape) + const merged = { + ...focusedExisting, + ...partial, + _type: focusedExisting._type, + shapeId: focusedExisting.shapeId, + } + // `partial` may be a broad TLShapePartial union; runtime merge is safe here + // because we anchor on a valid focused shape from the existing record. + return merged as unknown as FocusedShape +} + +// --------------------------------------------------------------------------- +// Output conversion helpers +// --------------------------------------------------------------------------- + +function convertOutputShape(editor: Editor, shape: TLShape): FocusedShape { + try { + return convertTldrawShapeToFocusedShape(editor, shape) + } catch { + return { + _type: 'unknown', + shapeId: convertTldrawIdToSimpleId(shape.id), + subType: shape.type, + note: '', + x: shape.x, + y: shape.y, + } + } +} + +function isTLShape(val: TLShape | FocusedShape | string | null | undefined): val is TLShape { + if (val === null || val === undefined || typeof val === 'string') return false + return 'typeName' in val && val.typeName === 'shape' +} + +/** + * Convert a return value from tldraw format to focused format based on the method spec. + * Uses targeted casts because the Proxy handler is inherently dynamic dispatch — + * the `result` type varies per method and can't be statically narrowed from the spec string. + */ +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents +type ProxyResult = + | TLShape + | TLShape[] + | TLShapeId + | TLShapeId[] + | Set + | Editor + | null + | undefined + | void + +function convertReturnValue(editor: Editor, proxy: Editor, spec: RetKind, result: ProxyResult) { + switch (spec) { + case 'this': + return proxy + case 'shape': { + const shape = result as TLShape | undefined + return shape && isTLShape(shape) ? convertOutputShape(editor, shape) : result + } + case 'shape-or-null': { + if (result === null || result === undefined) return result + const shape = result as TLShape + return isTLShape(shape) ? convertOutputShape(editor, shape) : result + } + case 'shapes': { + const shapes = result as TLShape[] + if (!Array.isArray(shapes)) return result + return shapes.map((s) => (isTLShape(s) ? convertOutputShape(editor, s) : s)) + } + case 'id': { + const id = result as TLShapeId + return typeof id === 'string' ? convertTldrawIdToSimpleId(id) : result + } + case 'id-or-null': { + if (result === null || result === undefined) return result + const id = result as TLShapeId + return typeof id === 'string' ? convertTldrawIdToSimpleId(id) : result + } + case 'ids': { + const ids = result as TLShapeId[] + if (!Array.isArray(ids)) return result + return ids.map((id) => (typeof id === 'string' ? convertTldrawIdToSimpleId(id) : id)) + } + case 'id-set': { + const idSet = result as Set + if (!(idSet instanceof Set)) return result + const out = new Set() + for (const id of idSet) { + out.add(convertTldrawIdToSimpleId(id)) + } + return out + } + } +} + +// --------------------------------------------------------------------------- +// Special-case handlers for create/update (arrow bindings) +// --------------------------------------------------------------------------- + +type CreateEditorMethod = (...args: Parameters) => Editor +type UpdateEditorMethod = (...args: Parameters) => Editor + +function handleCreateShape( + editor: Editor, + proxy: Editor, + partial: FocusedShape | TLCreateShapePartial, + realMethod: CreateEditorMethod +): Editor { + if (!isFocusedShape(partial)) { + realMethod.call(editor, partial) + return proxy + } + + const result = convertFocusedShapeToTldrawShape(editor, partial, { + defaultShape: getDefaultShape(partial._type), + }) + + editor.createShape(result.shape) + + if (result.bindings) { + for (const binding of result.bindings) { + editor.createBinding({ + type: binding.type, + fromId: binding.fromId, + toId: binding.toId, + props: binding.props, + meta: binding.meta, + }) + } + } + + return proxy +} + +function handleCreateShapes( + editor: Editor, + proxy: Editor, + partials: Array, + realMethod: CreateEditorMethod +): Editor { + if (!Array.isArray(partials)) { + realMethod.call(editor, partials) + return proxy + } + + for (const partial of partials) { + handleCreateShape(editor, proxy, partial, realMethod) + } + return proxy +} + +function handleUpdateShape( + editor: Editor, + proxy: Editor, + partial: FocusedShape | TLShapePartial, + realMethod: UpdateEditorMethod +): Editor { + const focusedPartial = toFocusedShapeIfPossible(editor, partial) + if (!focusedPartial) { + realMethod.call(editor, normalizeRawShapePartialId(partial as TLShapePartial)) + return proxy + } + + const shapeId = ensureTldrawId(focusedPartial.shapeId) + const existingShape = editor.getShape(shapeId) + if (!existingShape) { + return proxy + } + + const result = convertFocusedShapeToTldrawShape(editor, focusedPartial, { + defaultShape: existingShape, + }) + + editor.updateShape(result.shape) + + if (result.bindings) { + const existingBindings = editor.getBindingsFromShape(shapeId, 'arrow') + for (const binding of existingBindings) { + editor.deleteBinding(binding.id as TLBindingId) + } + for (const binding of result.bindings) { + editor.createBinding({ + type: binding.type, + fromId: binding.fromId, + toId: binding.toId, + props: binding.props, + meta: binding.meta, + }) + } + } + + return proxy +} + +function handleUpdateShapes( + editor: Editor, + proxy: Editor, + partials: Array, + realMethod: UpdateEditorMethod +): Editor { + if (!Array.isArray(partials)) { + realMethod.call(editor, partials) + return proxy + } + + for (const partial of partials) { + handleUpdateShape(editor, proxy, partial, realMethod) + } + return proxy +} + +// --------------------------------------------------------------------------- +// The Proxy factory +// --------------------------------------------------------------------------- + +export function createFocusedEditorProxy(editor: Editor, methodMap: MethodMap): Editor { + const proxy: Editor = new Proxy(editor, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + // Only intercept function calls on string-named properties + if (typeof prop !== 'string' || typeof value !== 'function') { + return value + } + + const spec = methodMap[prop] + + // --- Special-case: create/update need binding handling --- + if (prop === 'createShape') { + return (partial: FocusedShape | TLCreateShapePartial) => + handleCreateShape(target, proxy, partial, value as CreateEditorMethod) + } + if (prop === 'createShapes') { + return (partials: Array) => + handleCreateShapes(target, proxy, partials, value as CreateEditorMethod) + } + if (prop === 'updateShape') { + return (partial: FocusedShape | TLShapePartial) => + handleUpdateShape(target, proxy, partial, value as UpdateEditorMethod) + } + if (prop === 'updateShapes') { + return (partials: Array) => + handleUpdateShapes(target, proxy, partials, value as UpdateEditorMethod) + } + if (prop === 'animateShape') { + return (partial: FocusedShape | TLShapePartial, ...rest: [Record?]) => { + const focusedPartial = toFocusedShapeIfPossible(target, partial) + if (focusedPartial) { + const shapeId = ensureTldrawId(focusedPartial.shapeId) + const existing = target.getShape(shapeId) + if (existing) { + const converted = convertFocusedShapeToTldrawShape(target, focusedPartial, { + defaultShape: existing, + }) + value.call(target, converted.shape, ...rest) + return proxy + } + } + value.call(target, normalizeRawShapePartialId(partial as TLShapePartial), ...rest) + return proxy + } + } + if (prop === 'animateShapes') { + return ( + partials: Array, + ...rest: [Record?] + ) => { + if (Array.isArray(partials)) { + const converted = partials.map((p) => { + const focusedPartial = toFocusedShapeIfPossible(target, p) + if (focusedPartial) { + const shapeId = ensureTldrawId(focusedPartial.shapeId) + const existing = target.getShape(shapeId) + if (existing) { + return convertFocusedShapeToTldrawShape(target, focusedPartial, { + defaultShape: existing, + }).shape + } + } + return normalizeRawShapePartialId(p as TLShapePartial) + }) + value.call(target, converted, ...rest) + } else { + value.call(target, partials, ...rest) + } + return proxy + } + } + + // --- No spec: pass through, but still catch `this` returns --- + if (!spec) { + return (...args: Parameters) => { + const result = value.apply(target, args) + return result === target ? proxy : result + } + } + + // --- Generic handler for mapped methods --- + // The proxy handler is dynamic dispatch: args vary per intercepted method. + // We use targeted casts inside the switch rather than a single static signature. + return (...args: Parameters) => { + const convertedArgs: Parameters = [...args] + for (let i = 0; i < spec.args.length && i < convertedArgs.length; i++) { + const kind = spec.args[i] + const arg = convertedArgs[i] + switch (kind) { + case 'id': + if (typeof arg === 'string') { + convertedArgs[i] = ensureTldrawId(arg) + } + break + case 'id-or-shape': + convertedArgs[i] = convertIdOrShape( + arg as string | TLShapeId | TLShape | FocusedShape + ) + break + case 'ids-or-shapes': + convertedArgs[i] = convertIdsOrShapes( + arg as Array + ) + break + case 'spread-ids': + for (let j = i; j < convertedArgs.length; j++) { + convertedArgs[j] = convertIdOrShape( + convertedArgs[j] as string | TLShapeId | TLShape | FocusedShape + ) + } + break + } + } + + const result = value.apply(target, convertedArgs) + return convertReturnValue(target, proxy, spec.ret, result) + } + }, + }) as Editor + + return proxy +} diff --git a/apps/mcp-app/src/widget/focused/format.ts b/apps/mcp-app/src/widget/focused/format.ts new file mode 100644 index 000000000000..f9b5e5591083 --- /dev/null +++ b/apps/mcp-app/src/widget/focused/format.ts @@ -0,0 +1,366 @@ +/** + * Focused shape format types and conversion utilities. + * Ported from tldraw/tldraw templates/agent/shared/format/ + */ +import { TLDefaultFillStyle, TLDefaultSizeStyle, TLGeoShapeGeoStyle, TLShapeId } from 'tldraw' + +// ---- Colors ---- + +export const FOCUSED_COLORS = [ + 'red', + 'light-red', + 'green', + 'light-green', + 'blue', + 'light-blue', + 'orange', + 'yellow', + 'black', + 'violet', + 'light-violet', + 'grey', + 'white', +] as const + +export type FocusedColor = (typeof FOCUSED_COLORS)[number] + +export function asColor(color: string): FocusedColor { + if (FOCUSED_COLORS.includes(color as FocusedColor)) { + return color as FocusedColor + } + switch (color) { + case 'pink': + case 'light-pink': + return 'light-violet' + } + return 'black' +} + +// ---- Fill ---- + +export type FocusedFill = 'none' | 'tint' | 'background' | 'solid' | 'pattern' + +const FOCUSED_TO_SHAPE_FILLS: Record = { + none: 'none', + solid: 'lined-fill', + background: 'semi', + tint: 'solid', + pattern: 'pattern', +} + +const SHAPE_TO_FOCUSED_FILLS: Record = { + none: 'none', + fill: 'solid', + 'lined-fill': 'solid', + semi: 'background', + solid: 'tint', + pattern: 'pattern', +} + +export function convertFocusedFillToTldrawFill(fill: FocusedFill): TLDefaultFillStyle { + return FOCUSED_TO_SHAPE_FILLS[fill] +} + +export function convertTldrawFillToFocusedFill(fill: TLDefaultFillStyle): FocusedFill { + return SHAPE_TO_FOCUSED_FILLS[fill] +} + +// ---- Font Size ---- + +const FONT_SIZE_MULTIPLIERS: Record = { + s: 1.125, + m: 1.5, + l: 2.25, + xl: 2.75, +} + +const DEFAULT_BASE_FONT_SIZE = 16 + +export function convertFocusedFontSizeToTldrawFontSizeAndScale( + targetFontSize: number, + baseFontSize = DEFAULT_BASE_FONT_SIZE +) { + const fontSizeEntries = Object.entries(FONT_SIZE_MULTIPLIERS) + let closestSize = fontSizeEntries[0] + let closestPixelSize = closestSize[1] * baseFontSize + let minDifference = Math.abs(targetFontSize - closestPixelSize) + + for (const [size, multiplier] of fontSizeEntries) { + const pixelSize = multiplier * baseFontSize + const difference = Math.abs(targetFontSize - pixelSize) + if (difference < minDifference) { + minDifference = difference + closestSize = [size, multiplier] + closestPixelSize = pixelSize + } + } + + const textSize = closestSize[0] as TLDefaultSizeStyle + const scale = targetFontSize / closestPixelSize + + return { textSize, scale } +} + +export function convertTldrawFontSizeAndScaleToFocusedFontSize( + textSize: TLDefaultSizeStyle, + scale: number, + baseFontSize = DEFAULT_BASE_FONT_SIZE +) { + return Math.round(FONT_SIZE_MULTIPLIERS[textSize] * baseFontSize * scale) +} + +// ---- Geo Shape Types ---- + +export type FocusedGeoShapeType = + | 'rectangle' + | 'ellipse' + | 'triangle' + | 'diamond' + | 'hexagon' + | 'pill' + | 'cloud' + | 'x-box' + | 'check-box' + | 'heart' + | 'pentagon' + | 'octagon' + | 'star' + | 'parallelogram-right' + | 'parallelogram-left' + | 'trapezoid' + | 'fat-arrow-right' + | 'fat-arrow-left' + | 'fat-arrow-up' + | 'fat-arrow-down' + +export const FOCUSED_TO_GEO_TYPES: Record = { + rectangle: 'rectangle', + ellipse: 'ellipse', + triangle: 'triangle', + diamond: 'diamond', + hexagon: 'hexagon', + pill: 'oval', + cloud: 'cloud', + 'x-box': 'x-box', + 'check-box': 'check-box', + heart: 'heart', + pentagon: 'pentagon', + octagon: 'octagon', + star: 'star', + 'parallelogram-right': 'rhombus', + 'parallelogram-left': 'rhombus-2', + trapezoid: 'trapezoid', + 'fat-arrow-right': 'arrow-right', + 'fat-arrow-left': 'arrow-left', + 'fat-arrow-up': 'arrow-up', + 'fat-arrow-down': 'arrow-down', +} as const + +export const GEO_TO_FOCUSED_TYPES: Record = { + rectangle: 'rectangle', + ellipse: 'ellipse', + triangle: 'triangle', + diamond: 'diamond', + hexagon: 'hexagon', + oval: 'pill', + cloud: 'cloud', + 'x-box': 'x-box', + 'check-box': 'check-box', + heart: 'heart', + pentagon: 'pentagon', + octagon: 'octagon', + star: 'star', + rhombus: 'parallelogram-right', + 'rhombus-2': 'parallelogram-left', + trapezoid: 'trapezoid', + 'arrow-right': 'fat-arrow-right', + 'arrow-left': 'fat-arrow-left', + 'arrow-up': 'fat-arrow-up', + 'arrow-down': 'fat-arrow-down', +} as const + +// ---- ID Conversion ---- + +export function convertSimpleIdToTldrawId(id: string): TLShapeId { + if (id.startsWith('shape:')) return id as TLShapeId + return ('shape:' + id) as TLShapeId +} + +export function convertTldrawIdToSimpleId(id: TLShapeId): string { + return id.slice(6) +} + +// ---- Text Anchor ---- + +export type FocusedTextAnchor = + | 'bottom-center' + | 'bottom-left' + | 'bottom-right' + | 'center-left' + | 'center-right' + | 'center' + | 'top-center' + | 'top-left' + | 'top-right' + +// ---- Focused Shape Types ---- + +/** + * Geometric shapes like rectangles, ellipses, triangles, and other predefined forms. + * The _type field determines the geometric form. + */ +export interface FocusedGeoShape { + /** Geometric shape type */ + _type: FocusedGeoShapeType + /** Shape color */ + color: FocusedColor + /** Fill style */ + fill: FocusedFill + /** Height in pixels */ + h: number + /** Metadata note */ + note: string + /** Unique shape identifier */ + shapeId: string + /** Text label inside the shape */ + text?: string + /** Text alignment */ + textAlign?: 'start' | 'middle' | 'end' + /** Width in pixels */ + w: number + /** X position */ + x: number + /** Y position */ + y: number +} + +/** A straight line between two points. */ +export interface FocusedLineShape { + /** Always 'line' */ + _type: 'line' + /** Line color */ + color: FocusedColor + /** Metadata note */ + note: string + /** Unique shape identifier */ + shapeId: string + /** Start X */ + x1: number + /** End X */ + x2: number + /** Start Y */ + y1: number + /** End Y */ + y2: number +} + +/** A sticky note. */ +export interface FocusedNoteShape { + /** Always 'note' */ + _type: 'note' + /** Note color */ + color: FocusedColor + /** Metadata note */ + note: string + /** Unique shape identifier */ + shapeId: string + /** Note text content */ + text?: string + /** X position */ + x: number + /** Y position */ + y: number +} + +/** A text shape for placing text on the canvas. */ +export interface FocusedTextShape { + /** Always 'text' */ + _type: 'text' + /** Where the (x,y) point anchors on the text bounding box */ + anchor: FocusedTextAnchor + /** Text color */ + color: FocusedColor + /** Font size in pixels */ + fontSize?: number + /** Max width before wrapping (null = auto-size) */ + maxWidth: number | null + /** Metadata note */ + note: string + /** Unique shape identifier */ + shapeId: string + /** Text content */ + text: string + /** X position */ + x: number + /** Y position */ + y: number +} + +/** An arrow connecting two points or shapes. Use fromId/toId to bind to shapes. */ +export interface FocusedArrowShape { + /** Always 'arrow' */ + _type: 'arrow' + /** Arrow color */ + color: FocusedColor + /** Shape ID to bind the arrow start to */ + fromId: string | null + /** Metadata note */ + note: string + /** Unique shape identifier */ + shapeId: string + /** Arrow label text */ + text?: string + /** Shape ID to bind the arrow end to */ + toId: string | null + /** Start X */ + x1: number + /** End X */ + x2: number + /** Start Y */ + y1: number + /** End Y */ + y2: number + /** Signed bend amount for the curve */ + bend?: number + /** Arrow routing style */ + kind?: 'arc' | 'elbow' +} + +/** A freehand drawing. */ +export interface FocusedDrawShape { + /** Always 'draw' */ + _type: 'draw' + /** Stroke color */ + color: FocusedColor + /** Fill style */ + fill?: FocusedFill + /** Metadata note */ + note: string + /** Unique shape identifier */ + shapeId: string +} + +/** Fallback for unsupported shape types. */ +export interface FocusedUnknownShape { + /** Always 'unknown' */ + _type: 'unknown' + /** Metadata note */ + note: string + /** Unique shape identifier */ + shapeId: string + /** Original tldraw shape type */ + subType: string + /** X position */ + x: number + /** Y position */ + y: number +} + +export type FocusedShape = + | FocusedGeoShape + | FocusedLineShape + | FocusedNoteShape + | FocusedTextShape + | FocusedArrowShape + | FocusedDrawShape + | FocusedUnknownShape diff --git a/apps/mcp-app/src/widget/focused/to-focused.ts b/apps/mcp-app/src/widget/focused/to-focused.ts new file mode 100644 index 000000000000..2fa716a30dba --- /dev/null +++ b/apps/mcp-app/src/widget/focused/to-focused.ts @@ -0,0 +1,258 @@ +/** + * Convert tldraw TLShape → FocusedShape. + * Ported from tldraw/tldraw templates/agent/shared/format/convertTldrawShapeToFocusedShape.ts + */ +import { + Box, + createShapeId, + Editor, + isPageId, + reverseRecordsDiff, + TLArrowBinding, + TLArrowShape, + TLDrawShape, + TLGeoShape, + TLLineShape, + TLNoteShape, + TLShape, + TLTextShape, + Vec, +} from 'tldraw' +import { + convertTldrawFillToFocusedFill, + convertTldrawFontSizeAndScaleToFocusedFontSize, + convertTldrawIdToSimpleId, + GEO_TO_FOCUSED_TYPES, + type FocusedArrowShape, + type FocusedDrawShape, + type FocusedGeoShape, + type FocusedLineShape, + type FocusedNoteShape, + type FocusedShape, + type FocusedTextAnchor, + type FocusedTextShape, + type FocusedUnknownShape, +} from './format' + +export function convertTldrawShapeToFocusedShape(editor: Editor, shape: TLShape): FocusedShape { + switch (shape.type) { + case 'text': + return convertTextShapeToFocused(editor, shape as TLTextShape) + case 'geo': + return convertGeoShapeToFocused(editor, shape as TLGeoShape) + case 'line': + return convertLineShapeToFocused(editor, shape as TLLineShape) + case 'arrow': + return convertArrowShapeToFocused(editor, shape as TLArrowShape) + case 'note': + return convertNoteShapeToFocused(editor, shape as TLNoteShape) + case 'draw': + return convertDrawShapeToFocused(editor, shape as TLDrawShape) + default: + return convertUnknownShapeToFocused(editor, shape) + } +} + +function convertDrawShapeToFocused(_editor: Editor, shape: TLDrawShape): FocusedDrawShape { + return { + _type: 'draw', + color: shape.props.color, + fill: convertTldrawFillToFocusedFill(shape.props.fill), + note: (shape.meta.note as string) ?? '', + shapeId: convertTldrawIdToSimpleId(shape.id), + } +} + +function convertTextShapeToFocused(editor: Editor, shape: TLTextShape): FocusedTextShape { + const util = editor.getShapeUtil(shape) + const text = util.getText(shape) ?? '' + const bounds = getSimpleBounds(editor, shape) + const textSize = shape.props.size + const baseFontSize = editor.getTheme('default')?.fontSize ?? 16 + + const position = new Vec() + let anchor: FocusedTextAnchor = 'top-left' + switch (shape.props.textAlign) { + case 'middle': { + anchor = 'top-center' + position.x = bounds.center.x + position.y = bounds.top + break + } + case 'end': { + anchor = 'top-right' + position.x = bounds.right + position.y = bounds.top + break + } + case 'start': { + anchor = 'top-left' + position.x = bounds.left + position.y = bounds.top + break + } + } + + return { + _type: 'text', + anchor, + color: shape.props.color, + fontSize: convertTldrawFontSizeAndScaleToFocusedFontSize( + textSize, + shape.props.scale, + baseFontSize + ), + maxWidth: shape.props.autoSize ? null : shape.props.w, + note: (shape.meta.note as string) ?? '', + shapeId: convertTldrawIdToSimpleId(shape.id), + text, + x: position.x, + y: position.y, + } +} + +function convertGeoShapeToFocused(editor: Editor, shape: TLGeoShape): FocusedGeoShape { + const util = editor.getShapeUtil(shape) + const text = util.getText(shape) + const bounds = getSimpleBounds(editor, shape) + const shapeTextAlign = shape.props.align + + let newTextAlign: FocusedGeoShape['textAlign'] + switch (shapeTextAlign) { + case 'start-legacy': + newTextAlign = 'start' + break + case 'middle-legacy': + newTextAlign = 'middle' + break + case 'end-legacy': + newTextAlign = 'end' + break + default: + newTextAlign = shapeTextAlign + break + } + + return { + _type: GEO_TO_FOCUSED_TYPES[shape.props.geo], + color: shape.props.color, + fill: convertTldrawFillToFocusedFill(shape.props.fill), + h: shape.props.h, + note: (shape.meta.note as string) ?? '', + shapeId: convertTldrawIdToSimpleId(shape.id), + text: text ?? '', + textAlign: newTextAlign, + w: shape.props.w, + x: bounds.x, + y: bounds.y, + } +} + +function convertLineShapeToFocused(editor: Editor, shape: TLLineShape): FocusedLineShape { + const bounds = getSimpleBounds(editor, shape) + const points = Object.values(shape.props.points).sort((a, b) => a.index.localeCompare(b.index)) + return { + _type: 'line', + color: shape.props.color, + note: (shape.meta.note as string) ?? '', + shapeId: convertTldrawIdToSimpleId(shape.id), + x1: points[0].x + bounds.x, + x2: points[1].x + bounds.x, + y1: points[0].y + bounds.y, + y2: points[1].y + bounds.y, + } +} + +function convertArrowShapeToFocused(editor: Editor, shape: TLArrowShape): FocusedArrowShape { + const bounds = getSimpleBounds(editor, shape) + const bindings = editor.store.query.records('binding').get() + const arrowBindings = bindings.filter( + (b) => b.type === 'arrow' && b.fromId === shape.id + ) as TLArrowBinding[] + const startBinding = arrowBindings.find((b) => b.props.terminal === 'start') + const endBinding = arrowBindings.find((b) => b.props.terminal === 'end') + + return { + _type: 'arrow', + bend: shape.props.bend * -1, + kind: shape.props.kind, + color: shape.props.color, + fromId: startBinding ? convertTldrawIdToSimpleId(startBinding.toId) : null, + note: (shape.meta.note as string) ?? '', + shapeId: convertTldrawIdToSimpleId(shape.id), + text: editor.getShapeUtil(shape).getText(shape) ?? '', + toId: endBinding ? convertTldrawIdToSimpleId(endBinding.toId) : null, + x1: shape.props.start.x + bounds.x, + x2: shape.props.end.x + bounds.x, + y1: shape.props.start.y + bounds.y, + y2: shape.props.end.y + bounds.y, + } +} + +function convertNoteShapeToFocused(editor: Editor, shape: TLNoteShape): FocusedNoteShape { + const util = editor.getShapeUtil(shape) + const text = util.getText(shape) + const bounds = getSimpleBounds(editor, shape) + return { + _type: 'note', + color: shape.props.color, + note: (shape.meta.note as string) ?? '', + shapeId: convertTldrawIdToSimpleId(shape.id), + text: text ?? '', + x: bounds.x, + y: bounds.y, + } +} + +function convertUnknownShapeToFocused(editor: Editor, shape: TLShape): FocusedUnknownShape { + const bounds = getSimpleBounds(editor, shape) + return { + _type: 'unknown', + note: (shape.meta.note as string) ?? '', + shapeId: convertTldrawIdToSimpleId(shape.id), + subType: shape.type, + x: bounds.x, + y: bounds.y, + } +} + +function getSimpleBounds(editor: Editor, shape: TLShape): Box { + const pagePoint = getShapePagePoint(editor, shape) + + const props = shape.props as { w?: number; h?: number } + if (props.w !== undefined && props.h !== undefined) { + return new Box(pagePoint.x, pagePoint.y, props.w, props.h) + } + + const bounds = editor.getShapePageBounds(shape) + if (bounds) { + return new Box(pagePoint.x, pagePoint.y, bounds.w, bounds.h) + } + + let mockBounds: Box | undefined + const diff = editor.store.extractingChanges(() => { + editor.run( + () => { + const mockId = createShapeId() + editor.createShape({ ...shape, id: mockId }) + mockBounds = editor.getShapePageBounds(mockId) + }, + { ignoreShapeLock: false, history: 'ignore' } + ) + }) + const reverseDiff = reverseRecordsDiff(diff) + editor.store.applyDiff(reverseDiff) + + if (!mockBounds) { + throw new Error('Failed to get bounds for shape') + } + return new Box(pagePoint.x, pagePoint.y, mockBounds.w, mockBounds.h) +} + +function getShapePagePoint(editor: Editor, shape: TLShape): Vec { + if (isPageId(shape.parentId)) { + return new Vec(shape.x, shape.y) + } + const parentTransform = editor.getShapePageTransform(shape.parentId) + return parentTransform.applyToPoint(new Vec(shape.x, shape.y)) +} diff --git a/apps/mcp-app/src/widget/focused/to-tldraw.ts b/apps/mcp-app/src/widget/focused/to-tldraw.ts new file mode 100644 index 000000000000..63b5ac879aad --- /dev/null +++ b/apps/mcp-app/src/widget/focused/to-tldraw.ts @@ -0,0 +1,570 @@ +/** + * Convert FocusedShape → tldraw TLShape. + * Ported from tldraw/tldraw templates/agent/shared/format/convertFocusedShapeToTldrawShape.ts + */ +import { + Box, + createShapeId, + Editor, + IndexKey, + reverseRecordsDiff, + TLArrowShape, + TLBindingCreate, + TLDefaultSizeStyle, + TLDrawShape, + TLGeoShape, + TLLineShape, + TLNoteShape, + TLShape, + TLTextShape, + toRichText, + Vec, +} from 'tldraw' +import { + asColor, + convertFocusedFillToTldrawFill, + convertFocusedFontSizeToTldrawFontSizeAndScale, + convertSimpleIdToTldrawId, + FOCUSED_TO_GEO_TYPES, + type FocusedArrowShape, + type FocusedDrawShape, + type FocusedGeoShape, + type FocusedGeoShapeType, + type FocusedLineShape, + type FocusedNoteShape, + type FocusedShape, + type FocusedTextShape, + type FocusedUnknownShape, +} from './format' + +export function convertFocusedShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLShape; bindings?: TLBindingCreate[] } { + switch (focusedShape._type) { + case 'text': + return convertTextShapeToTldrawShape(editor, focusedShape, { defaultShape }) + case 'line': + return convertLineShapeToTldrawShape(editor, focusedShape, { defaultShape }) + case 'arrow': + return convertArrowShapeToTldrawShape(editor, focusedShape, { defaultShape }) + case 'note': + return convertNoteShapeToTldrawShape(editor, focusedShape, { defaultShape }) + case 'draw': + return convertDrawShapeToTldrawShape(editor, focusedShape, { defaultShape }) + case 'unknown': + return convertUnknownShapeToTldrawShape(editor, focusedShape, { defaultShape }) + default: + // Geo types (rectangle, ellipse, etc.) + return convertGeoShapeToTldrawShape(editor, focusedShape as FocusedGeoShape, { + defaultShape, + }) + } +} + +export function convertFocusedGeoTypeToTldrawGeoGeoType(type: FocusedGeoShapeType) { + return FOCUSED_TO_GEO_TYPES[type] +} + +function convertTextShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedTextShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLTextShape } { + const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId) + const defaultTextShape = defaultShape as TLTextShape + const baseFontSize = editor.getTheme('default')?.fontSize ?? 16 + + let textSize: TLDefaultSizeStyle = 's' + let scale = 1 + + if (focusedShape.fontSize) { + const result = convertFocusedFontSizeToTldrawFontSizeAndScale( + focusedShape.fontSize, + baseFontSize + ) + textSize = result.textSize + scale = result.scale + } else if (defaultTextShape.props?.size) { + textSize = defaultTextShape.props.size + scale = defaultTextShape.props.scale ?? 1 + } + + const autoSize = + focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null + ? false + : (defaultTextShape.props?.autoSize ?? true) + const font = defaultTextShape.props?.font ?? 'draw' + + let richText + if (focusedShape.text !== undefined) { + richText = toRichText(focusedShape.text) + } else if (defaultTextShape.props?.richText) { + richText = defaultTextShape.props.richText + } else { + richText = toRichText('') + } + + let textAlign: TLTextShape['props']['textAlign'] = defaultTextShape.props?.textAlign ?? 'start' + switch (focusedShape.anchor) { + case 'top-left': + case 'bottom-left': + case 'center-left': + textAlign = 'start' + break + case 'top-center': + case 'bottom-center': + case 'center': + textAlign = 'middle' + break + case 'top-right': + case 'bottom-right': + case 'center-right': + textAlign = 'end' + break + } + + const unpositionedShape: TLTextShape = { + id: shapeId, + type: 'text', + typeName: 'shape', + x: 0, + y: 0, + rotation: defaultTextShape.rotation ?? 0, + index: defaultTextShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: defaultTextShape.parentId ?? editor.getCurrentPageId(), + isLocked: defaultTextShape.isLocked ?? false, + opacity: defaultTextShape.opacity ?? 1, + props: { + size: textSize, + scale, + richText, + color: asColor(focusedShape.color ?? defaultTextShape.props?.color ?? 'black'), + textAlign, + autoSize, + w: + focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null + ? focusedShape.maxWidth + : (defaultTextShape.props?.w ?? 100), + font, + }, + meta: { + note: focusedShape.note ?? defaultTextShape.meta?.note ?? '', + }, + } + + const unpositionedBounds = getDummyBounds(editor, unpositionedShape) + + const position = new Vec(defaultTextShape.x ?? 0, defaultTextShape.y ?? 0) + const x = focusedShape.x ?? defaultTextShape.x ?? 0 + const y = focusedShape.y ?? defaultTextShape.y ?? 0 + switch (focusedShape.anchor) { + case 'top-center': { + position.x = x - unpositionedBounds.w / 2 + position.y = y + break + } + case 'top-right': { + position.x = x - unpositionedBounds.w + position.y = y + break + } + case 'bottom-left': { + position.x = x + position.y = y - unpositionedBounds.h + break + } + case 'bottom-center': { + position.x = x - unpositionedBounds.w / 2 + position.y = y - unpositionedBounds.h + break + } + case 'bottom-right': { + position.x = x - unpositionedBounds.w + position.y = y - unpositionedBounds.h + break + } + case 'center-left': { + position.x = x + position.y = y - unpositionedBounds.h / 2 + break + } + case 'center-right': { + position.x = x - unpositionedBounds.w + position.y = y - unpositionedBounds.h / 2 + break + } + case 'center': { + position.x = focusedShape.x - unpositionedBounds.w / 2 + position.y = focusedShape.y - unpositionedBounds.h / 2 + break + } + case 'top-left': + default: { + position.x = x + position.y = y + break + } + } + + return { + shape: { ...unpositionedShape, x: position.x, y: position.y }, + } +} + +function convertLineShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedLineShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLShape } { + const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId) + const defaultLineShape = defaultShape as TLLineShape + + const x1 = focusedShape.x1 ?? 0 + const y1 = focusedShape.y1 ?? 0 + const x2 = focusedShape.x2 ?? 0 + const y2 = focusedShape.y2 ?? 0 + const minX = Math.min(x1, x2) + const minY = Math.min(y1, y2) + + return { + shape: { + id: shapeId, + type: 'line', + typeName: 'shape', + x: minX, + y: minY, + rotation: defaultLineShape.rotation ?? 0, + index: defaultLineShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: defaultLineShape.parentId ?? editor.getCurrentPageId(), + isLocked: defaultLineShape.isLocked ?? false, + opacity: defaultLineShape.opacity ?? 1, + props: { + size: defaultLineShape.props?.size ?? 's', + points: { + a1: { id: 'a1', index: 'a1' as IndexKey, x: x1 - minX, y: y1 - minY }, + a2: { id: 'a2', index: 'a2' as IndexKey, x: x2 - minX, y: y2 - minY }, + }, + color: asColor(focusedShape.color ?? defaultLineShape.props?.color ?? 'black'), + dash: defaultLineShape.props?.dash ?? 'draw', + scale: defaultLineShape.props?.scale ?? 1, + spline: defaultLineShape.props?.spline ?? 'line', + }, + meta: { + note: focusedShape.note ?? defaultLineShape.meta?.note ?? '', + }, + }, + } +} + +function convertArrowShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedArrowShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLShape; bindings?: TLBindingCreate[] } { + const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId) + const defaultArrowShape = defaultShape as TLArrowShape + + const x1 = focusedShape.x1 ?? defaultArrowShape.props?.start?.x ?? 0 + const y1 = focusedShape.y1 ?? defaultArrowShape.props?.start?.y ?? 0 + const x2 = focusedShape.x2 ?? defaultArrowShape.props?.end?.x ?? 0 + const y2 = focusedShape.y2 ?? defaultArrowShape.props?.end?.y ?? 0 + const minX = Math.min(x1, x2) + const minY = Math.min(y1, y2) + + let richText + if (focusedShape.text !== undefined) { + richText = toRichText(focusedShape.text) + } else if (defaultArrowShape.props?.richText) { + richText = defaultArrowShape.props.richText + } else { + richText = toRichText('') + } + + const shape = { + id: shapeId, + type: 'arrow' as const, + typeName: 'shape' as const, + x: minX, + y: minY, + rotation: defaultArrowShape.rotation ?? 0, + index: defaultArrowShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: defaultArrowShape.parentId ?? editor.getCurrentPageId(), + isLocked: defaultArrowShape.isLocked ?? false, + opacity: defaultArrowShape.opacity ?? 1, + props: { + arrowheadEnd: defaultArrowShape.props?.arrowheadEnd ?? 'arrow', + arrowheadStart: defaultArrowShape.props?.arrowheadStart ?? 'none', + bend: (focusedShape.bend ?? (defaultArrowShape.props?.bend ?? 0) * -1) * -1, + color: asColor(focusedShape.color ?? defaultArrowShape.props?.color ?? 'black'), + dash: defaultArrowShape.props?.dash ?? 'draw', + elbowMidPoint: defaultArrowShape.props?.elbowMidPoint ?? 0.5, + end: { x: x2 - minX, y: y2 - minY }, + fill: defaultArrowShape.props?.fill ?? 'none', + font: defaultArrowShape.props?.font ?? 'draw', + kind: focusedShape.kind ?? defaultArrowShape.props?.kind ?? 'arc', + labelColor: defaultArrowShape.props?.labelColor ?? 'black', + labelPosition: defaultArrowShape.props?.labelPosition ?? 0.5, + richText, + scale: defaultArrowShape.props?.scale ?? 1, + size: defaultArrowShape.props?.size ?? 's', + start: { x: x1 - minX, y: y1 - minY }, + }, + meta: { + note: focusedShape.note ?? defaultArrowShape.meta?.note ?? '', + }, + } + + const bindings: TLBindingCreate[] = [] + + if (focusedShape.fromId) { + const fromId = convertSimpleIdToTldrawId(focusedShape.fromId) + const startShape = editor.getShape(fromId) + if (startShape) { + bindings.push({ + type: 'arrow', + typeName: 'binding', + fromId: shapeId, + toId: startShape.id, + props: { + normalizedAnchor: { x: 0.5, y: 0.5 }, + isExact: false, + isPrecise: false, + terminal: 'start', + }, + meta: {}, + }) + } + } + + if (focusedShape.toId) { + const toId = convertSimpleIdToTldrawId(focusedShape.toId) + const endShape = editor.getShape(toId) + if (endShape) { + bindings.push({ + type: 'arrow', + typeName: 'binding', + fromId: shapeId, + toId: endShape.id, + props: { + normalizedAnchor: { x: 0.5, y: 0.5 }, + isExact: false, + isPrecise: false, + terminal: 'end', + }, + meta: {}, + }) + } + } + + return { + shape, + bindings: bindings.length > 0 ? bindings : undefined, + } +} + +function convertGeoShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedGeoShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLShape } { + const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId) + const shapeType = convertFocusedGeoTypeToTldrawGeoGeoType(focusedShape._type) + const defaultGeoShape = defaultShape as TLGeoShape + + let richText + if (focusedShape.text !== undefined) { + richText = toRichText(focusedShape.text) + } else if (defaultGeoShape.props?.richText) { + richText = defaultGeoShape.props.richText + } else { + richText = toRichText('') + } + + let fill + if (focusedShape.fill !== undefined) { + fill = convertFocusedFillToTldrawFill(focusedShape.fill) ?? 'none' + } else if (defaultGeoShape.props?.fill) { + fill = defaultGeoShape.props.fill + } else { + fill = convertFocusedFillToTldrawFill('none') + } + + return { + shape: { + id: shapeId, + type: 'geo', + typeName: 'shape', + x: focusedShape.x ?? defaultGeoShape.x ?? 0, + y: focusedShape.y ?? defaultGeoShape.y ?? 0, + rotation: defaultGeoShape.rotation ?? 0, + index: defaultGeoShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: defaultGeoShape.parentId ?? editor.getCurrentPageId(), + isLocked: defaultGeoShape.isLocked ?? false, + opacity: defaultGeoShape.opacity ?? 1, + props: { + align: focusedShape.textAlign ?? defaultGeoShape.props?.align ?? 'start', + color: asColor(focusedShape.color ?? defaultGeoShape.props?.color ?? 'black'), + dash: defaultGeoShape.props?.dash ?? 'draw', + fill, + font: defaultGeoShape.props?.font ?? 'draw', + geo: shapeType, + growY: defaultGeoShape.props?.growY ?? 0, + h: focusedShape.h ?? defaultGeoShape.props?.h ?? 100, + labelColor: defaultGeoShape.props?.labelColor ?? 'black', + richText, + scale: defaultGeoShape.props?.scale ?? 1, + size: defaultGeoShape.props?.size ?? 's', + url: defaultGeoShape.props?.url ?? '', + verticalAlign: defaultGeoShape.props?.verticalAlign ?? 'start', + w: focusedShape.w ?? defaultGeoShape.props?.w ?? 100, + }, + meta: { + note: focusedShape.note ?? defaultGeoShape.meta?.note ?? '', + }, + }, + } +} + +function convertNoteShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedNoteShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLShape } { + const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId) + const defaultNoteShape = defaultShape as TLNoteShape + + let richText + if (focusedShape.text !== undefined) { + richText = toRichText(focusedShape.text) + } else if (defaultNoteShape.props?.richText) { + richText = defaultNoteShape.props.richText + } else { + richText = toRichText('') + } + + return { + shape: { + id: shapeId, + type: 'note', + typeName: 'shape', + x: focusedShape.x ?? defaultNoteShape.x ?? 0, + y: focusedShape.y ?? defaultNoteShape.y ?? 0, + rotation: defaultNoteShape.rotation ?? 0, + index: defaultNoteShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: defaultNoteShape.parentId ?? editor.getCurrentPageId(), + isLocked: defaultNoteShape.isLocked ?? false, + opacity: defaultNoteShape.opacity ?? 1, + props: { + color: asColor(focusedShape.color ?? defaultNoteShape.props?.color ?? 'black'), + richText, + size: defaultNoteShape.props?.size ?? 's', + align: defaultNoteShape.props?.align ?? 'middle', + font: defaultNoteShape.props?.font ?? 'draw', + fontSizeAdjustment: defaultNoteShape.props?.fontSizeAdjustment ?? 0, + growY: defaultNoteShape.props?.growY ?? 0, + labelColor: defaultNoteShape.props?.labelColor ?? 'black', + scale: defaultNoteShape.props?.scale ?? 1, + url: defaultNoteShape.props?.url ?? '', + verticalAlign: defaultNoteShape.props?.verticalAlign ?? 'middle', + textFirstEditedBy: defaultNoteShape.props?.textFirstEditedBy ?? null, + }, + meta: { + note: focusedShape.note ?? defaultNoteShape.meta?.note ?? '', + }, + }, + } +} + +function convertDrawShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedDrawShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLShape } { + const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId) + const defaultDrawShape = defaultShape as TLDrawShape + + let fill + if (focusedShape.fill !== undefined) { + fill = convertFocusedFillToTldrawFill(focusedShape.fill) + } else if (defaultDrawShape.props?.fill) { + fill = defaultDrawShape.props.fill + } else { + fill = convertFocusedFillToTldrawFill('none') + } + + return { + shape: { + id: shapeId, + type: 'draw', + typeName: 'shape', + x: defaultDrawShape.x ?? 0, + y: defaultDrawShape.y ?? 0, + rotation: defaultDrawShape.rotation ?? 0, + index: defaultDrawShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: defaultDrawShape.parentId ?? editor.getCurrentPageId(), + isLocked: defaultDrawShape.isLocked ?? false, + opacity: defaultDrawShape.opacity ?? 1, + props: { + ...editor.getShapeUtil('draw').getDefaultProps(), + color: asColor(focusedShape.color ?? defaultDrawShape.props?.color ?? 'black'), + fill, + }, + meta: { + note: focusedShape.note ?? defaultDrawShape.meta?.note ?? '', + }, + }, + } +} + +function convertUnknownShapeToTldrawShape( + editor: Editor, + focusedShape: FocusedUnknownShape, + { defaultShape }: { defaultShape: Partial } +): { shape: TLShape } { + const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId) + + return { + shape: { + id: shapeId, + type: defaultShape.type ?? 'geo', + typeName: 'shape', + x: focusedShape.x ?? defaultShape.x ?? 0, + y: focusedShape.y ?? defaultShape.y ?? 0, + rotation: defaultShape.rotation ?? 0, + index: defaultShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: defaultShape.parentId ?? editor.getCurrentPageId(), + isLocked: defaultShape.isLocked ?? false, + opacity: defaultShape.opacity ?? 1, + props: defaultShape.props ?? ({} as any), + meta: { + note: focusedShape.note ?? defaultShape.meta?.note ?? '', + }, + }, + } +} + +export function getDummyBounds(editor: Editor, shape: TLShape): Box { + const bounds = editor.getShapePageBounds(shape) + if (bounds) return bounds + + let dummyBounds: Box | undefined + const diff = editor.store.extractingChanges(() => { + editor.run( + () => { + const dummyId = createShapeId() + editor.createShape({ ...shape, id: dummyId }) + dummyBounds = editor.getShapePageBounds(dummyId) + }, + { ignoreShapeLock: false, history: 'ignore' } + ) + }) + const reverseDiff = reverseRecordsDiff(diff) + editor.store.applyDiff(reverseDiff) + + if (!dummyBounds) { + throw new Error('Failed to get bounds for shape') + } + return dummyBounds +} diff --git a/apps/mcp-app/src/widget/mcp-app.css b/apps/mcp-app/src/widget/mcp-app.css new file mode 100644 index 000000000000..03f10d198763 --- /dev/null +++ b/apps/mcp-app/src/widget/mcp-app.css @@ -0,0 +1,111 @@ +.mcp-app__share-zone { + display: flex; + gap: 4px; +} + +.mcp-app__build-button { + flex: 0 0 auto; + position: relative; + box-sizing: border-box; + border: none; + background: var(--tl-color-muted-2); + color: var(--tl-color-muted-1); + font: inherit; + font-weight: 600; + padding: var(--tl-space-3) var(--tl-space-4); + border-radius: var(--tl-radius-2); + margin: var(--tl-space-2); + cursor: not-allowed; + pointer-events: all; +} + +.mcp-app__build-button--enabled { + background: var(--tl-color-primary); + color: #fff; + cursor: pointer; +} + +.mcp-app__error-banner { + width: 100%; + height: 30px; + display: flex; + align-items: center; + font-size: 12px; + line-height: 1.4; + margin-top: -1px; + gap: 10px; + color: #2e2e2e; +} + +.mcp-app__error-banner--dark { + color: #f0f0f0; +} + +.mcp-app__error-logo { + width: 16px; + height: 16px; + filter: brightness(0) saturate(100%); +} + +.mcp-app__error-logo--dark { + filter: none; +} + +.mcp-app__error-label { + font-weight: 500; +} + +.mcp-app__error-message { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #5f5f5f; + font-weight: 400; +} + +.mcp-app__error-message--dark { + color: #bdbdbd; +} + +.mcp-app__canvas-layout { + width: 100%; + display: flex; + flex-direction: column; + height: 600px; + gap: 8px; +} + +.mcp-app__canvas-layout--fullscreen { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--tl-color-background); +} + +.mcp-app__canvas-surface { + width: 100%; + height: 600px; + position: relative; +} + +.mcp-app__canvas-surface--with-dev-log { + height: calc(600px - 140px - 8px); +} + +.mcp-app__canvas-surface--fullscreen { + flex: 1; + min-height: 0; +} + +.mcp-app__status { + padding: 20px; +} + +.mcp-app__status--error { + color: red; +} + +.mcp-app__status--connecting { + opacity: 0.5; +} diff --git a/apps/mcp-app/src/widget/mcp-app.tsx b/apps/mcp-app/src/widget/mcp-app.tsx index d85d382b33eb..8d3a24ba2b8c 100644 --- a/apps/mcp-app/src/widget/mcp-app.tsx +++ b/apps/mcp-app/src/widget/mcp-app.tsx @@ -5,18 +5,20 @@ import { type TLAsset, type TLBindingCreate, type TLComponents, - type TLShape, type TLShapeId, + type TLUiEventHandler, DefaultToolbar, DefaultToolbarContent, Editor, Tldraw, TldrawUiIcon, - structuredClone, useEditor, useValue, } from 'tldraw' import 'tldraw/tldraw.css' +import './mcp-app.css' +import tldrawLogoUrl from '../../plugins/tldraw-mcp/assets/logo.svg' +import { primeEmbeddedMethodMap } from '../shared/generated-data' import { MCP_SERVER_DESCRIPTION, MCP_SERVER_NAME, @@ -27,37 +29,29 @@ import { import type { MCP_APP_HOST_NAMES } from '../shared/types' import { isHostCodeEditor, resolveMcpAppHostNameFromClientInfo } from '../shared/utils' import { McpAppContext } from './app-context' +import { DEV_LOG_PANEL_HEIGHT, DevLogPanel, useDevLog } from './dev-log' +import { executeCode } from './exec-helpers' import { exportTldr } from './export-tldr' import { ImageDropGuard, uiOverrides } from './image-guard' import { type CanvasSnapshot, - getEditorBindings, getEmbeddedBootstrap, getLatestCheckpointSnapshot, loadLocalSnapshot, parseCheckpointFromToolResult, + clearCanvasContext, pushCanvasContext, saveCheckpointToServer, saveLocalSnapshot, + setCurrentCanvasId, setCurrentSessionId, + syncEditorState, } from './persistence' -import { applyPreviewToEditor, applySnapshot, zoomToFitRequestShapes } from './snapshot' -import { - extractToolArguments, - mergeShapesById, - parseNewBlankCanvasFlag, - toCreatePreviewShapes, - toDeletePreviewSnapshot, - toUpdatePreviewShapes, -} from './streaming' +import { applySnapshot, zoomToFitRequestShapes } from './snapshot' const LICENSE_KEY = import.meta.env.VITE_TLDRAW_LICENSE_KEY as string -const EDITOR_HEIGHT = 600 const SAVE_DEBOUNCE_MS = 500 -const MAX_DEV_LOG_ENTRIES = 200 -const DEV_LOG_PANEL_HEIGHT = 140 -const DEV_LOG_PANEL_GAP = 8 function SharePanelContent() { const editor = useEditor() @@ -99,7 +93,7 @@ function SharePanelContent() { }, [app, lastEditor]) return ( -
+
{canDownload && ( @@ -169,29 +149,42 @@ const tldrawComponents: TLComponents = { Toolbar: DynamicToolbar, } +const ERROR_BANNER_HEIGHT = 30 + +function parseHostTheme(value: unknown): 'light' | 'dark' | null { + return value === 'dark' || value === 'light' ? value : null +} + function TldrawCanvas({ app }: { app: App }) { const [displayMode, setDisplayMode] = useState>('inline') const [containerHeight, setContainerHeight] = useState(null) const [lastEditor, setLastEditor] = useState<'user' | 'ai'>('ai') - const [isDev, setIsDev] = useState(false) - const [isDevLogVisible, setIsDevLogVisible] = useState(false) - const [devLogEntries, setDevLogEntries] = useState([]) const [hostContext, setHostContext] = useState(() => app.getHostContext()) + const [hostTheme, setHostTheme] = useState<'light' | 'dark'>(() => { + const initialTheme = parseHostTheme( + (app.getHostContext() as { theme?: string } | undefined)?.theme + ) + return initialTheme ?? 'light' + }) + const [canvasTheme, setCanvasTheme] = useState<'light' | 'dark'>(hostTheme) + const [execError, setExecError] = useState(null) const editorRef = useRef(null) const pendingSnapshotRef = useRef(null) - const pendingPreviewSnapshotRef = useRef(null) - const previewActiveRef = useRef(false) - const createFromBlankPreviewRef = useRef(false) const committedSnapshotRef = useRef({ shapes: [], assets: [] }) const checkpointIdRef = useRef(null) const removeStoreListenerRef = useRef<(() => void) | null>(null) - const isDevRef = useRef(false) + const editorReadyResolveRef = useRef<((editor: Editor) => void) | null>(null) + const editorReadyPromiseRef = useRef | null>(null) const saveTimerRef = useRef(null) - const requestShapeIdsRef = useRef>(new Set()) const hasUserEditedSinceAiRef = useRef(false) const lastEditorRef = useRef<'user' | 'ai'>('ai') + const execPartialDebounceRef = useRef(null) + const hasExecRunRef = useRef(false) + + const { isDev, isDevLogVisible, devLogEntries, logIfDevMode, toggleDevLog, enableDevMode } = + useDevLog() const hostCapabilities = useMemo(() => { return app.getHostCapabilities() @@ -202,6 +195,32 @@ function TldrawCanvas({ app }: { app: App }) { }, [app]) const isMobilePlatform = hostContext?.platform === 'mobile' + const isDarkTheme = canvasTheme === 'dark' + + const syncThemeFromEditor = useCallback(() => { + const editor = editorRef.current + if (!editor) return + setCanvasTheme(editor.user.getIsDarkMode() ? 'dark' : 'light') + }, []) + + const applyHostThemeToEditor = useCallback((theme: 'light' | 'dark') => { + setHostTheme(theme) + setCanvasTheme(theme) + const editor = editorRef.current + if (!editor) return + editor.user.updateUserPreferences({ colorScheme: theme }) + }, []) + + const handleUiEvent = useCallback( + (name) => { + const eventName = name as string + if (eventName !== 'toggle-dark-mode' && eventName !== 'color-scheme') return + queueMicrotask(() => { + syncThemeFromEditor() + }) + }, + [syncThemeFromEditor] + ) const canFullscreen = useMemo(() => { if (isMobilePlatform) return false @@ -216,10 +235,6 @@ function TldrawCanvas({ app }: { app: App }) { const [hostName, setHostName] = useState(null) const devLogPanelHeight = isDev && isDevLogVisible ? DEV_LOG_PANEL_HEIGHT : 0 - const inlineCanvasHeight = - devLogPanelHeight > 0 - ? Math.max(EDITOR_HEIGHT - devLogPanelHeight - DEV_LOG_PANEL_GAP, 240) - : EDITOR_HEIGHT useEffect(() => { const resolved = resolveMcpAppHostNameFromClientInfo(hostInfo?.name ?? '') @@ -228,6 +243,21 @@ function TldrawCanvas({ app }: { app: App }) { } }, [hostInfo]) + const teardownEditor = useCallback(() => { + removeStoreListenerRef.current?.() + removeStoreListenerRef.current = null + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current) + saveTimerRef.current = null + } + if (execPartialDebounceRef.current !== null) { + window.clearTimeout(execPartialDebounceRef.current) + execPartialDebounceRef.current = null + } + editorRef.current?.dispose() + editorRef.current = null + }, []) + const markAiActivity = useCallback(() => { hasUserEditedSinceAiRef.current = false if (lastEditorRef.current !== 'ai') { @@ -244,39 +274,16 @@ function TldrawCanvas({ app }: { app: App }) { } }, []) - const logIfDevMode = useCallback((message: string) => { - if (!isDevRef.current) return - setDevLogEntries((entries) => { - const timestamp = new Date().toLocaleTimeString() - const nextEntries = [...entries, `[${timestamp}] ${message}`] - return nextEntries.slice(-MAX_DEV_LOG_ENTRIES) - }) - }, []) - - const toggleDevLog = useCallback(() => { - setIsDevLogVisible((visible) => !visible) - }, []) - const toggleFullscreen = useCallback(async () => { const newMode = displayMode === 'fullscreen' ? 'inline' : 'fullscreen' if (newMode === 'fullscreen' && (isMobilePlatform || !canFullscreen)) { return } - // Sync current editor state before leaving fullscreen if (newMode === 'inline') { const editor = editorRef.current if (editor) { - const shapes = [...editor.getCurrentPageShapes()].map((s) => structuredClone(s)) - const assets = [...editor.getAssets()].map((a) => structuredClone(a)) - const bindings = getEditorBindings(editor) - committedSnapshotRef.current = { shapes, assets, bindings } - pushCanvasContext(app, editor) - const cpId = checkpointIdRef.current - if (cpId) { - saveLocalSnapshot(cpId, shapes, assets, bindings) - saveCheckpointToServer(app, cpId, editor) - } + committedSnapshotRef.current = syncEditorState(app, editor, checkpointIdRef.current) } } @@ -318,58 +325,17 @@ function TldrawCanvas({ app }: { app: App }) { ] ) - const renderPreviewSnapshot = useCallback((previewSnapshot: CanvasSnapshot) => { - previewActiveRef.current = true - + /** Returns the editor, waiting for mount if it hasn't happened yet. */ + const waitForEditor = useCallback((): Promise => { const editor = editorRef.current - if (!editor) { - pendingPreviewSnapshotRef.current = previewSnapshot - return - } - - applyPreviewToEditor(editor, previewSnapshot, committedSnapshotRef.current) - zoomToFitRequestShapes(editor, requestShapeIdsRef.current) - }, []) + if (editor) return Promise.resolve(editor) - const renderPreviewShapes = useCallback( - ( - previewShapes: TLShape[], - mode: 'create' | 'update', - createFromBlank = false, - previewBindings: TLBindingCreate[] = [] - ) => { - if (previewShapes.length <= 0) return - for (const shape of previewShapes) { - requestShapeIdsRef.current.add(shape.id) - } - const committed = committedSnapshotRef.current - const editor = editorRef.current - const baseShapes = createFromBlank - ? [] - : editor - ? [...editor.getCurrentPageShapes()] - : committed.shapes - const previewSnapshot: CanvasSnapshot = { - shapes: createFromBlank - ? previewShapes.map((shape) => structuredClone(shape)) - : mergeShapesById(baseShapes, previewShapes), - assets: [], - bindings: previewBindings, - } - renderPreviewSnapshot(previewSnapshot) - }, - [renderPreviewSnapshot] - ) + if (editorReadyPromiseRef.current) return editorReadyPromiseRef.current - const clearPreviewAndRestoreCommitted = useCallback(() => { - if (!previewActiveRef.current) return - previewActiveRef.current = false - createFromBlankPreviewRef.current = false - pendingPreviewSnapshotRef.current = null - const editor = editorRef.current - if (editor) { - applySnapshot(editor, committedSnapshotRef.current) - } + editorReadyPromiseRef.current = new Promise((resolve) => { + editorReadyResolveRef.current = resolve + }) + return editorReadyPromiseRef.current }, []) const scheduleSave = useCallback(() => { @@ -379,112 +345,40 @@ function TldrawCanvas({ app }: { app: App }) { saveTimerRef.current = window.setTimeout(() => { saveTimerRef.current = null const editor = editorRef.current - const cpId = checkpointIdRef.current if (!editor) return - - // Push model context - pushCanvasContext(app, editor) - - // Persist to localStorage + server - if (cpId) { - const shapes = [...editor.getCurrentPageShapes()].map((s) => structuredClone(s)) - const assets = [...editor.getAssets()].map((a) => structuredClone(a)) - const bindings = getEditorBindings(editor) - saveLocalSnapshot(cpId, shapes, assets, bindings) - saveCheckpointToServer(app, cpId, editor) - } + syncEditorState(app, editor, checkpointIdRef.current) }, SAVE_DEBOUNCE_MS) }, [app]) - const applyPreviewFromToolInput = useCallback( - (input: unknown, isPartial: boolean) => { - const committed = committedSnapshotRef.current - - app.updateModelContext({ - content: [ - { - type: 'text', - text: `Applying preview from tool input ${JSON.stringify(input, null, 2)}`, - }, - ], - }) - - const args = extractToolArguments(input) - if (!args) return - markAiActivity() - - const isCreateCall = args.shapesJson !== undefined || args.new_blank_canvas !== undefined - const isUpdateCall = args.updatesJson !== undefined - const isDeleteCall = args.shapeIdsJson !== undefined - - if (isUpdateCall || isDeleteCall) { - createFromBlankPreviewRef.current = false - } - - if (isCreateCall) { - if (args.new_blank_canvas === undefined) { - createFromBlankPreviewRef.current = false - } - const blankFlag = parseNewBlankCanvasFlag(args.new_blank_canvas, isPartial) - if (blankFlag === true) createFromBlankPreviewRef.current = true - if (blankFlag === false) createFromBlankPreviewRef.current = false - } - - const createPreview = toCreatePreviewShapes(args.shapesJson, isPartial) - if (createPreview.shapes.length > 0) { - renderPreviewShapes( - createPreview.shapes, - 'create', - createFromBlankPreviewRef.current, - createPreview.bindings - ) - return - } - - const editor = editorRef.current - const liveShapes = editor ? [...editor.getCurrentPageShapes()] : committed.shapes - const updatePreview = toUpdatePreviewShapes(args.updatesJson, isPartial, liveShapes) - if (updatePreview.shapes.length > 0) { - renderPreviewShapes(updatePreview.shapes, 'update', false, updatePreview.bindings) - return - } - - const liveForDelete: CanvasSnapshot = { - shapes: editor ? [...editor.getCurrentPageShapes()] : committed.shapes, - assets: [], - } - const deletePreviewSnapshot = toDeletePreviewSnapshot( - args.shapeIdsJson, - isPartial, - liveForDelete - ) - if (!deletePreviewSnapshot) return - - renderPreviewSnapshot(deletePreviewSnapshot) - }, - [app, markAiActivity, renderPreviewShapes, renderPreviewSnapshot] - ) - useEffect(() => { setHostContext(app.getHostContext()) + const initialTheme = parseHostTheme( + (app.getHostContext() as { theme?: string } | undefined)?.theme + ) + if (initialTheme) { + applyHostThemeToEditor(initialTheme) + } - // Sync bootstrap: read session ID + checkpoint data embedded in the HTML - // by the resource handler. This avoids async callServerTool on mount which - // caused issues on ChatGPT and was too slow for streaming preview. + logIfDevMode('Bootstrap loading...') const bootstrap = getEmbeddedBootstrap() + primeEmbeddedMethodMap() + + // Delete the bootstrap data from the window object to prevent it from being used again. + delete window.__TLDRAW_BOOTSTRAP__ + if (bootstrap) { setCurrentSessionId(bootstrap.sessionId) - isDevRef.current = bootstrap.isDev - setIsDev(bootstrap.isDev) + if (bootstrap.canvasId) { + setCurrentCanvasId(bootstrap.canvasId) + } if (bootstrap.isDev) { - setIsDevLogVisible(true) + enableDevMode() } logIfDevMode( - `Bootstrap loaded for session ${bootstrap.sessionId}${bootstrap.isDev ? ' (dev mode)' : ''}` + `Bootstrap loaded for session ${bootstrap.sessionId}, canvas ${bootstrap.canvasId ?? 'none'}${bootstrap.isDev ? ' (dev mode)' : ''}` ) - if (bootstrap.snapshot && bootstrap.snapshot.shapes.length > 0) { - // Don't overwrite if a tool result already committed shapes + if (bootstrap.snapshot) { if (committedSnapshotRef.current.shapes.length === 0) { const snapshot: CanvasSnapshot = { shapes: bootstrap.snapshot.shapes, @@ -506,13 +400,8 @@ function TldrawCanvas({ app }: { app: App }) { } } } else { - // No embedded snapshot — try session-scoped localStorage const latestSnapshot = getLatestCheckpointSnapshot() - if ( - latestSnapshot && - latestSnapshot.shapes.length > 0 && - committedSnapshotRef.current.shapes.length === 0 - ) { + if (latestSnapshot && committedSnapshotRef.current.shapes.length === 0) { logIfDevMode( `Restored latest local snapshot with ${latestSnapshot.shapes.length} shape(s)` ) @@ -528,31 +417,29 @@ function TldrawCanvas({ app }: { app: App }) { } app.onhostcontextchanged = (ctx) => { - setHostContext(app.getHostContext() ?? ctx) + const nextContext = app.getHostContext() ?? ctx + setHostContext(nextContext) + const nextTheme = parseHostTheme( + (ctx as { theme?: string } | undefined)?.theme ?? + (nextContext as { theme?: string } | undefined)?.theme + ) + if (nextTheme) { + // Host theme changes take precedence over local preference changes. + applyHostThemeToEditor(nextTheme) + } const dims = ctx.containerDimensions if (dims && 'height' in dims) { setContainerHeight(dims.height) } - // Only update display mode if the host explicitly provides it if (ctx.displayMode !== undefined) { const newMode = ctx.displayMode === 'fullscreen' ? 'fullscreen' : 'inline' - // Sync editor state before host exits fullscreen if (newMode !== 'fullscreen') { const editor = editorRef.current if (editor) { - const shapes = [...editor.getCurrentPageShapes()].map((s) => structuredClone(s)) - const assets = [...editor.getAssets()].map((a) => structuredClone(a)) - const bindings = getEditorBindings(editor) - committedSnapshotRef.current = { shapes, assets, bindings } - pushCanvasContext(app, editor) - const cpId = checkpointIdRef.current - if (cpId) { - saveLocalSnapshot(cpId, shapes, assets, bindings) - saveCheckpointToServer(app, cpId, editor) - } + committedSnapshotRef.current = syncEditorState(app, editor, checkpointIdRef.current) } } @@ -560,25 +447,179 @@ function TldrawCanvas({ app }: { app: App }) { } } - app.onteardown = async () => { - return {} + const runExec = (code: string, source: string, canvasId?: string) => { + if (hasExecRunRef.current) { + logIfDevMode(`Exec: skipping duplicate exec from ${source}`) + return + } + hasExecRunRef.current = true + + logIfDevMode(`Exec: running from ${source}`) + markAiActivity() + + void (async () => { + logIfDevMode('Exec: waiting for editor...') + const editor = await waitForEditor() + + if (canvasId) { + setCurrentCanvasId(canvasId) + + if (editor.getCurrentPageShapeIds().size === 0) { + logIfDevMode(`Exec: canvas empty, fetching state for canvasId=${canvasId}`) + try { + const response = await app.callServerTool({ + name: '_get_canvas_state', + arguments: { canvasId }, + }) + const res = response as any + let data: any = null + // Try structuredContent first, fall back to parsing text content + if (res?.structuredContent) { + data = res.structuredContent + } else if (Array.isArray(res?.content)) { + const textItem = res.content.find( + (c: any) => c.type === 'text' && typeof c.text === 'string' + ) + if (textItem) { + try { + data = JSON.parse(textItem.text) + } catch { + // not JSON + } + } + } + if (data && Array.isArray(data.shapes) && data.shapes.length > 0) { + const snapshot: CanvasSnapshot = { + shapes: data.shapes, + assets: Array.isArray(data.assets) ? data.assets : [], + bindings: Array.isArray(data.bindings) ? data.bindings : [], + } + applySnapshot(editor, snapshot) + committedSnapshotRef.current = snapshot + if (typeof data.checkpointId === 'string') { + checkpointIdRef.current = data.checkpointId + } + logIfDevMode( + `Exec: restored ${data.shapes.length} shape(s) from server for canvasId=${canvasId}` + ) + } else { + logIfDevMode(`Exec: no shapes returned from server for canvasId=${canvasId}`) + } + } catch (err) { + logIfDevMode(`Exec: failed to fetch canvas state: ${err}`) + } + } + } + + logIfDevMode('Exec: editor ready, executing code') + + const execResult = await executeCode(editor, code) + logIfDevMode( + `Exec ${execResult.success ? 'succeeded' : 'failed'}: ${JSON.stringify(execResult.result ?? execResult.error)}` + ) + + // Call _exec_callback FIRST to get the server-assigned canvasId + const callbackArgs = execResult.success + ? { channel: 'exec', result: { success: true, result: execResult.result } } + : { channel: 'exec', result: { success: false, error: execResult.error } } + try { + const cbResponse = await app.callServerTool({ + name: '_exec_callback', + arguments: callbackArgs, + }) + const cbRes = cbResponse as any + let cbData: any = null + if (Array.isArray(cbRes?.content)) { + const textItem = cbRes.content.find( + (c: any) => c.type === 'text' && typeof c.text === 'string' + ) + if (textItem) { + try { + cbData = JSON.parse(textItem.text) + } catch { + // not JSON + } + } + } + if (cbData?.canvasId) { + setCurrentCanvasId(cbData.canvasId) + logIfDevMode(`Exec: server canvasId=${cbData.canvasId}`) + } + logIfDevMode('Exec: _exec_callback succeeded') + } catch (err) { + logIfDevMode(`Exec: _exec_callback failed: ${err}`) + } + + if (execResult.success) { + const cpId = checkpointIdRef.current ?? crypto.randomUUID() + checkpointIdRef.current = cpId + + const resultStr = + execResult.result !== undefined ? JSON.stringify(execResult.result, null, 2) : undefined + committedSnapshotRef.current = syncEditorState(app, editor, cpId, { + message: resultStr + ? `Code executed successfully on canvas. Return value:\n${resultStr}` + : 'Code executed successfully on canvas.', + }) + + const snapshot = committedSnapshotRef.current + const allShapeIds = new Set(snapshot.shapes.map((s) => s.id)) + zoomToFitRequestShapes(editor, allShapeIds) + } else { + clearCanvasContext(app, { + message: + 'Canvas context was cleared because code execution failed. Fix the error before using the canvas context again.', + }) + teardownEditor() + setExecError(execResult.error ?? 'Unknown error') + void app.sendSizeChanged({ width: 400, height: ERROR_BANNER_HEIGHT }) + } + })() } - app.ontoolinputpartial = (input) => { - logIfDevMode(`Received partial tool input: ${JSON.stringify(input)}`) - applyPreviewFromToolInput(input, true) + app.ontoolinput = (params) => { + logIfDevMode('Exec: ontoolinput called') + const code = params.arguments?.code + if (typeof code !== 'string' || !code.trim()) return + const canvasId = + typeof params.arguments?.canvasId === 'string' ? params.arguments.canvasId : undefined + + if (execPartialDebounceRef.current !== null) { + window.clearTimeout(execPartialDebounceRef.current) + } + execPartialDebounceRef.current = window.setTimeout(() => { + execPartialDebounceRef.current = null + runExec(code, 'ontoolinput (debounced)', canvasId) + }, 500) } - app.ontoolinput = (input) => { - logIfDevMode(`Received tool input: ${JSON.stringify(input)}`) - applyPreviewFromToolInput(input, false) + app.ontoolinputpartial = (params) => { + const code = params.arguments?.code + if (typeof code !== 'string' || !code.trim()) return + const canvasId = + typeof params.arguments?.canvasId === 'string' ? params.arguments.canvasId : undefined + + if (execPartialDebounceRef.current !== null) { + window.clearTimeout(execPartialDebounceRef.current) + } + execPartialDebounceRef.current = window.setTimeout(() => { + execPartialDebounceRef.current = null + runExec(code, 'ontoolinputpartial (debounced)', canvasId) + }, 1000) } - app.ontoolresult = (result) => { + app.onteardown = async () => { + return {} + } + + app.ontoolresult = async (result) => { + logIfDevMode('Exec: ontoolresult called') + hasExecRunRef.current = false + markAiActivity() + const checkpoint = parseCheckpointFromToolResult(result) if (!checkpoint) return logIfDevMode(`Received tool result for checkpoint ${checkpoint.checkpointId}`) - markAiActivity() const { checkpointId, @@ -586,46 +627,20 @@ function TldrawCanvas({ app }: { app: App }) { shapes: resultShapes, assets: resultAssets, bindings: resultBindings, - action, - hadBaseShapes, - newBlankCanvas, } = checkpoint checkpointIdRef.current = checkpointId - // Keep session ID in sync (e.g. if it wasn't available from bootstrap) if (sessionId) { setCurrentSessionId(sessionId) } - // Clear preview state - previewActiveRef.current = false - createFromBlankPreviewRef.current = false - pendingPreviewSnapshotRef.current = null - - // Check localStorage for user edits (handles remount case) const localSnapshot = loadLocalSnapshot(checkpointId) - let finalShapes = localSnapshot ? localSnapshot.shapes : resultShapes - let finalAssets: TLAsset[] = localSnapshot ? localSnapshot.assets : resultAssets - let finalBindings: TLBindingCreate[] = localSnapshot ? localSnapshot.bindings : resultBindings - - // Client-side merge fallback: if the server didn't have base shapes for a create - // (e.g. server process restarted between tool calls, losing in-memory state) - // and this wasn't a blank canvas request, merge the new shapes with the latest - // checkpoint from localStorage. - if (!localSnapshot && action === 'create' && !hadBaseShapes && !newBlankCanvas) { - const latestSnapshot = getLatestCheckpointSnapshot() - if (latestSnapshot && latestSnapshot.shapes.length > 0) { - finalShapes = mergeShapesById(latestSnapshot.shapes, resultShapes) - // Merge assets too - const assetMap = new Map(latestSnapshot.assets.map((a) => [a.id, a])) - for (const a of resultAssets) assetMap.set(a.id, a) - finalAssets = [...assetMap.values()] - // Merge bindings - finalBindings = [...latestSnapshot.bindings, ...resultBindings] - } - } + const finalShapes = localSnapshot ? localSnapshot.shapes : resultShapes + const finalAssets: TLAsset[] = localSnapshot ? localSnapshot.assets : resultAssets + const finalBindings: TLBindingCreate[] = localSnapshot + ? localSnapshot.bindings + : resultBindings - // Capture previous committed IDs for zoom diff fallback const previousCommittedIds = new Set(committedSnapshotRef.current.shapes.map((s) => s.id)) const snapshot: CanvasSnapshot = { @@ -638,59 +653,45 @@ function TldrawCanvas({ app }: { app: App }) { const editor = editorRef.current if (!editor) { pendingSnapshotRef.current = snapshot - requestShapeIdsRef.current = new Set() return } applySnapshot(editor, snapshot) - // Zoom to fit shapes from this request — but skip if restoring - // from localStorage (reload/remount case where user may have panned) if (!localSnapshot) { - let zoomShapeIds: Set = requestShapeIdsRef.current - if (zoomShapeIds.size === 0) { - // No streaming preview happened — compute new shapes from diff - const newIds = new Set() - for (const shape of finalShapes) { - if (!previousCommittedIds.has(shape.id)) newIds.add(shape.id) - } - zoomShapeIds = newIds + const newIds = new Set() + for (const shape of finalShapes) { + if (!previousCommittedIds.has(shape.id)) newIds.add(shape.id) } - zoomToFitRequestShapes(editor, zoomShapeIds) + zoomToFitRequestShapes(editor, newIds) } - requestShapeIdsRef.current = new Set() - // Persist to localStorage (ensures it's saved even on first render) saveLocalSnapshot(checkpointId, finalShapes, finalAssets, finalBindings) - - // Immediately push checkpoint to server so the next tool call can fork from it. - // This is critical: the server may restart between tool calls, losing in-memory state. - saveCheckpointToServer(app, checkpointId, editor) - + void saveCheckpointToServer(app, checkpointId, editor) pushCanvasContext(app, editor) } app.ontoolcancelled = (_params) => { - clearPreviewAndRestoreCommitted() - requestShapeIdsRef.current = new Set() + hasExecRunRef.current = false + if (execPartialDebounceRef.current !== null) { + window.clearTimeout(execPartialDebounceRef.current) + execPartialDebounceRef.current = null + } markAiActivity() logIfDevMode('Tool invocation cancelled') } return () => { - if (saveTimerRef.current !== null) { - window.clearTimeout(saveTimerRef.current) - saveTimerRef.current = null - } - removeStoreListenerRef.current?.() - removeStoreListenerRef.current = null + teardownEditor() } }, [ app, + applyHostThemeToEditor, + enableDevMode, logIfDevMode, - applyPreviewFromToolInput, - clearPreviewAndRestoreCommitted, markAiActivity, + teardownEditor, + waitForEditor, ]) useEffect(() => { @@ -705,7 +706,6 @@ function TldrawCanvas({ app }: { app: App }) { .catch(() => {}) }, [app, displayMode, isMobilePlatform]) - // Set explicit height on html/body in fullscreen useEffect(() => { if (displayMode === 'fullscreen' && containerHeight) { const h = `${containerHeight}px` @@ -720,6 +720,13 @@ function TldrawCanvas({ app }: { app: App }) { const handleMount = useCallback( (editor: Editor) => { editorRef.current = editor + editor.user.updateUserPreferences({ colorScheme: canvasTheme }) + + if (editorReadyResolveRef.current) { + editorReadyResolveRef.current(editor) + editorReadyResolveRef.current = null + editorReadyPromiseRef.current = null + } removeStoreListenerRef.current?.() removeStoreListenerRef.current = editor.store.listen( @@ -730,7 +737,6 @@ function TldrawCanvas({ app }: { app: App }) { { source: 'user', scope: 'document' } ) - // Keep viewport center stable when the container resizes (fullscreen toggle). editor.sideEffects.registerAfterChangeHandler('instance', (prev, next) => { const pb = prev.screenBounds const nb = next.screenBounds @@ -751,68 +757,50 @@ function TldrawCanvas({ app }: { app: App }) { }) }) - // Apply any snapshot that arrived before the editor was ready const pendingSnapshot = pendingSnapshotRef.current if (pendingSnapshot) { pendingSnapshotRef.current = null applySnapshot(editor, pendingSnapshot) } - const pendingPreviewSnapshot = pendingPreviewSnapshotRef.current - if (pendingPreviewSnapshot) { - pendingPreviewSnapshotRef.current = null - applySnapshot(editor, pendingPreviewSnapshot) - zoomToFitRequestShapes(editor, requestShapeIdsRef.current) - } + pushCanvasContext(app, editor) }, - [markUserEdit, scheduleSave] + [app, canvasTheme, markUserEdit, scheduleSave] ) + if (execError) { + return ( +
+ tldraw logo + Error editing canvas: + + {execError} + +
+ ) + } + const isFullscreen = displayMode === 'fullscreen' return (
0 && !isFullscreen ? ' mcp-app__canvas-surface--with-dev-log' : ''}`} > @@ -820,24 +808,7 @@ function TldrawCanvas({ app }: { app: App }) {
{isDev && isDevLogVisible && ( -
- {devLogEntries.length > 0 ? devLogEntries.join('\n') : 'Dev log ready.'} -
+ )}
@@ -863,9 +834,9 @@ function McpApp() { return (
{error ? ( -
Error: {error.message}
+
Error: {error.message}
) : !isConnected || !app ? ( -
Status: {status}
+
Status: {status}
) : ( )} diff --git a/apps/mcp-app/src/widget/persistence.ts b/apps/mcp-app/src/widget/persistence.ts index 6db51ef6b5a5..b2410d17f89c 100644 --- a/apps/mcp-app/src/widget/persistence.ts +++ b/apps/mcp-app/src/widget/persistence.ts @@ -1,9 +1,8 @@ import type { App } from '@modelcontextprotocol/ext-apps/react' import { Editor, structuredClone } from 'tldraw' import type { TLAsset, TLBindingCreate, TLShape } from 'tldraw' -import { convertTldrawRecordToFocusedShape } from '../focused-shape-converters' -import type { FocusedShape } from '../focused-shape-schema' import { isPlainObject } from '../shared/utils' +import { convertTldrawShapeToFocusedShape } from './focused/to-focused' export interface CanvasSnapshot { shapes: TLShape[] @@ -11,21 +10,27 @@ export interface CanvasSnapshot { bindings?: TLBindingCreate[] } -// --- Session-scoped localStorage persistence --- +// --- Canvas-scoped localStorage persistence --- let currentSessionId: string | null = null +let currentCanvasId: string | null = null export function setCurrentSessionId(id: string): void { currentSessionId = id } -export function getCurrentSessionId(): string | null { - return currentSessionId +export function setCurrentCanvasId(id: string): void { + currentCanvasId = id +} + +export function getCurrentCanvasId(): string | null { + return currentCanvasId } function localStorageKey(checkpointId: string): string { - if (!currentSessionId) return `tldraw:${checkpointId}` - return `tldraw:${currentSessionId}:${checkpointId}` + if (currentCanvasId) return `tldraw:canvas:${currentCanvasId}:${checkpointId}` + if (currentSessionId) return `tldraw:${currentSessionId}:${checkpointId}` + return `tldraw:${checkpointId}` } function parseSnapshotData( @@ -38,18 +43,20 @@ function parseSnapshotData( (s: unknown): s is TLShape => isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string' ) - return shapes.length > 0 ? { shapes, assets: [], bindings: [] } : null + if (parsed.length > 0 && shapes.length === 0) return null + return { shapes, assets: [], bindings: [] } } - if (!isPlainObject(parsed)) return null - const shapes = (Array.isArray(parsed.shapes) ? parsed.shapes : []).filter( + if (!isPlainObject(parsed) || !Array.isArray(parsed.shapes)) return null + const shapes = parsed.shapes.filter( (s: unknown): s is TLShape => isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string' ) + if (parsed.shapes.length > 0 && shapes.length === 0) return null const assets = (Array.isArray(parsed.assets) ? parsed.assets : []).filter( (a: unknown): a is TLAsset => isPlainObject(a) && typeof a.id === 'string' ) const bindings = (Array.isArray(parsed.bindings) ? parsed.bindings : []) as TLBindingCreate[] - return shapes.length > 0 ? { shapes, assets, bindings } : null + return { shapes, assets, bindings } } export function loadLocalSnapshot( @@ -71,16 +78,19 @@ export function saveLocalSnapshot( assets: TLAsset[] = [], bindings: TLBindingCreate[] = [] ): void { - if (!currentSessionId) return + const scopeId = currentCanvasId ?? currentSessionId + if (!scopeId) return try { // eslint-disable-next-line tldraw/no-direct-storage localStorage.setItem( localStorageKey(checkpointId), JSON.stringify({ shapes, assets, bindings }) ) - // Track this as the latest checkpoint for this session + const latestKey = currentCanvasId + ? `tldraw:canvas:${currentCanvasId}:latest` + : `tldraw:${currentSessionId}:latest` // eslint-disable-next-line tldraw/no-direct-storage - localStorage.setItem(`tldraw:${currentSessionId}:latest`, checkpointId) + localStorage.setItem(latestKey, checkpointId) } catch { // localStorage may be full or unavailable. } @@ -91,10 +101,14 @@ export function getLatestCheckpointSnapshot(): { assets: TLAsset[] bindings: TLBindingCreate[] } | null { - if (!currentSessionId) return null + const scopeId = currentCanvasId ?? currentSessionId + if (!scopeId) return null try { + const latestKey = currentCanvasId + ? `tldraw:canvas:${currentCanvasId}:latest` + : `tldraw:${currentSessionId}:latest` // eslint-disable-next-line tldraw/no-direct-storage - const latestId = localStorage.getItem(`tldraw:${currentSessionId}:latest`) + const latestId = localStorage.getItem(latestKey) if (!latestId) return null return loadLocalSnapshot(latestId) } catch { @@ -112,8 +126,11 @@ declare global { export function getEmbeddedBootstrap(): { sessionId: string + canvasId?: string checkpointId?: string isDev: boolean + workerOrigin?: string + mcpSessionId?: string snapshot?: CanvasSnapshot } | null { const data = window.__TLDRAW_BOOTSTRAP__ @@ -121,11 +138,14 @@ export function getEmbeddedBootstrap(): { const sessionId = typeof data.sessionId === 'string' ? data.sessionId : null if (!sessionId) return null + const canvasId = typeof data.canvasId === 'string' ? data.canvasId : undefined const checkpointId = typeof data.checkpointId === 'string' ? data.checkpointId : undefined const isDev = data.isDev === true + const workerOrigin = typeof data.workerOrigin === 'string' ? data.workerOrigin : undefined + const mcpSessionId = typeof data.mcpSessionId === 'string' ? data.mcpSessionId : undefined let snapshot: CanvasSnapshot | undefined - if (Array.isArray(data.shapes) && data.shapes.length > 0) { + if (Array.isArray(data.shapes)) { const shapes = data.shapes.filter( (s: unknown): s is TLShape => isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string' @@ -134,12 +154,12 @@ export function getEmbeddedBootstrap(): { (a: unknown): a is TLAsset => isPlainObject(a) && typeof a.id === 'string' ) const bindings = (Array.isArray(data.bindings) ? data.bindings : []) as TLBindingCreate[] - if (shapes.length > 0) { + if (data.shapes.length === 0 || shapes.length > 0) { snapshot = { shapes, assets, bindings } } } - return { sessionId, checkpointId, isDev, snapshot } + return { sessionId, canvasId, checkpointId, isDev, workerOrigin, mcpSessionId, snapshot } } // --- Tool result parsing --- @@ -201,41 +221,65 @@ export function parseCheckpointFromToolResult(result: unknown): CheckpointResult } } -// --- Canvas context push --- +// --- Snapshot capture + sync --- -export function getEditorFocusedShapes(editor: Editor): FocusedShape[] { - const shapes: FocusedShape[] = [] - for (const record of editor.getCurrentPageShapes()) { - try { - const focused = convertTldrawRecordToFocusedShape(record) - // Enrich arrow shapes with binding info from the editor - if (focused._type === 'arrow') { - const arrowBindings = editor.getBindingsFromShape(record.id, 'arrow') - for (const binding of arrowBindings) { - const terminal = (binding.props as { terminal?: string }).terminal - const targetId = binding.toId.replace(/^shape:/, '') - if (terminal === 'start') { - focused.fromId = targetId - } else if (terminal === 'end') { - focused.toId = targetId - } - } - } - shapes.push(focused) - } catch { - // Ignore malformed records. - } +/** + * Capture the current editor state as a cloned snapshot. + */ +export function captureEditorSnapshot(editor: Editor): CanvasSnapshot { + return { + shapes: [...editor.getCurrentPageShapes()].map((s) => structuredClone(s)), + assets: [...editor.getAssets()].map((a) => structuredClone(a)), + bindings: getEditorBindings(editor), } - return shapes } -export function pushCanvasContext(app: App, editor: Editor) { - const focusedShapes = getEditorFocusedShapes(editor) - const text = - focusedShapes.length > 0 - ? `Current canvas shapes:\n${JSON.stringify(focusedShapes, null, 2)}` - : 'Canvas is empty.' - app.updateModelContext({ content: [{ type: 'text', text }] }) +/** + * Push model context and persist the editor state to localStorage + server. + * Returns the captured snapshot so the caller can store it. + */ +export function syncEditorState( + app: App, + editor: Editor, + checkpointId: string | null, + opts?: { message?: string } +): CanvasSnapshot { + const snapshot = captureEditorSnapshot(editor) + pushCanvasContext(app, editor, opts) + if (checkpointId) { + saveLocalSnapshot(checkpointId, snapshot.shapes, snapshot.assets, snapshot.bindings ?? []) + void saveCheckpointToServer(app, checkpointId, editor) + } + return snapshot +} + +// --- Canvas context push --- + +export function pushCanvasContext(app: App, editor: Editor, opts?: { message?: string }) { + const shapes = [...editor.getCurrentPageShapes()].map((shape) => + convertTldrawShapeToFocusedShape(editor, shape) + ) + + const canvasStatus = shapes.length > 0 ? `Current canvas state is attached.` : 'Canvas is empty.' + + const text = opts?.message ? `${opts.message}\n\n${canvasStatus}` : canvasStatus + + void app.updateModelContext({ + content: [{ type: 'text', text }], + structuredContent: { + shapes, + }, + }) +} + +export function clearCanvasContext(app: App, opts?: { message?: string }) { + const text = opts?.message ?? 'Canvas context was cleared.' + void app.updateModelContext({ + content: [{ type: 'text', text }], + structuredContent: { + shapes: [], + }, + }) } export function getEditorBindings(editor: Editor): TLBindingCreate[] { @@ -256,7 +300,11 @@ export function getEditorBindings(editor: Editor): TLBindingCreate[] { return bindings } -export function saveCheckpointToServer(app: App, checkpointId: string, editor: Editor) { +export async function saveCheckpointToServer( + app: App, + checkpointId: string, + editor: Editor +): Promise { const shapes = [...editor.getCurrentPageShapes()].map((s) => structuredClone(s)) const assets = [...editor.getAssets()].map((a) => structuredClone(a)) const bindings = getEditorBindings(editor) @@ -270,12 +318,16 @@ export function saveCheckpointToServer(app: App, checkpointId: string, editor: E if (bindings.length > 0) { args.bindingsJson = JSON.stringify(bindings) } - app - .callServerTool({ + if (currentCanvasId) { + args.canvasId = currentCanvasId + } + try { + const result = await app.callServerTool({ name: 'save_checkpoint', arguments: args, }) - .catch(() => { - // Best-effort; failure is non-fatal. - }) + return result.isError !== true + } catch { + return false + } } diff --git a/apps/mcp-app/src/widget/snapshot.ts b/apps/mcp-app/src/widget/snapshot.ts index 113208d1d0ac..d0cf9718223b 100644 --- a/apps/mcp-app/src/widget/snapshot.ts +++ b/apps/mcp-app/src/widget/snapshot.ts @@ -155,57 +155,3 @@ export function applySnapshot(editor: Editor, snapshot: CanvasSnapshot) { ) }) } - -/** - * Non-destructive preview apply: adds new shapes and updates existing ones - * without deleting user-drawn shapes. Only removes committed shapes that - * are absent from the preview (e.g. when createFromBlank clears the canvas). - */ -export function applyPreviewToEditor( - editor: Editor, - snapshot: CanvasSnapshot, - committedSnapshot: CanvasSnapshot -) { - const nextShapes = snapshot.shapes.map((shape) => structuredClone(shape)) - const nextShapeIds = new Set(nextShapes.map((s) => s.id)) - const committedIds = new Set(committedSnapshot.shapes.map((s) => s.id)) - const nextBindings = (snapshot.bindings ?? []) as TLBindingCreate[] - - editor.store.mergeRemoteChanges(() => { - editor.run( - () => { - const existingIds = [...editor.getCurrentPageShapeIds()] - const toDelete = existingIds.filter((id) => committedIds.has(id) && !nextShapeIds.has(id)) - if (toDelete.length > 0) { - editor.deleteShapes(toDelete) - } - - if (nextShapes.length <= 0) return - - const remainingIds = new Set([...editor.getCurrentPageShapeIds()]) - - const toCreate: TLShape[] = [] - const toUpdate: TLShape[] = [] - - for (const shape of nextShapes) { - if (remainingIds.has(shape.id)) { - toUpdate.push(shape) - } else { - const { index: _index, ...partial } = shape - toCreate.push(partial as TLShape) - } - } - - if (toUpdate.length > 0) editor.updateShapes(toUpdate) - if (toCreate.length > 0) editor.createShapes(toCreate) - - // Re-run auto-sizing so growY / fontSizeAdjustment are correct - forceAutoSize(editor) - - // Create bindings after all shapes are on the page - applyBindings(editor, nextBindings) - }, - { history: 'ignore' } - ) - }) -} diff --git a/apps/mcp-app/src/widget/streaming.ts b/apps/mcp-app/src/widget/streaming.ts deleted file mode 100644 index 3eb9482a70d6..000000000000 --- a/apps/mcp-app/src/widget/streaming.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { structuredClone } from 'tldraw' -import type { TLBindingCreate, TLShape } from 'tldraw' -import { - convertFocusedShapeToTldrawRecord, - convertFocusedShapesToTldrawRecords, - convertTldrawRecordToFocusedShape, -} from '../focused-shape-converters' -import { - type FocusedShape, - FocusedShapeSchema, - FocusedShapeUpdateSchema, -} from '../focused-shape-schema' -import { healJsonArrayString } from '../parse-json' -import { deepMerge, isPlainObject, normalizeShapeId, toSimpleShapeId } from '../shared/utils' -import type { CanvasSnapshot } from './persistence' - -export function parsePartialJsonArray(value: string): unknown[] { - const trimmed = healJsonArrayString(value.trim()) - if (!trimmed.startsWith('[')) return [] - - const candidates: string[] = [trimmed] - if (!trimmed.endsWith(']')) { - candidates.push(`${trimmed}]`) - } - - const withoutTrailingBracket = trimmed.endsWith(']') ? trimmed.slice(0, -1) : trimmed - const lastComma = withoutTrailingBracket.lastIndexOf(',') - if (lastComma > 0) { - candidates.push(`${withoutTrailingBracket.slice(0, lastComma)}]`) - } - - const lastObjectEnd = withoutTrailingBracket.lastIndexOf('}') - if (lastObjectEnd >= 0) { - candidates.push(`${withoutTrailingBracket.slice(0, lastObjectEnd + 1)}]`) - } - - for (const candidate of new Set(candidates)) { - try { - const parsed = JSON.parse(candidate) - if (Array.isArray(parsed)) return parsed - } catch { - // Keep trying best-effort candidates. - } - } - - return [] -} - -function dropPotentiallyIncompleteTail(items: T[]): T[] { - if (items.length <= 1) return [] - return items.slice(0, -1) -} - -export function extractToolArguments(input: unknown): Record | null { - if (!isPlainObject(input)) return null - const args = input.arguments - return isPlainObject(args) ? args : input -} - -export function parsePreviewArray(value: unknown, isPartial: boolean): unknown[] { - let parsedItems: unknown[] = [] - if (Array.isArray(value)) { - parsedItems = value - } else if (typeof value === 'string') { - parsedItems = parsePartialJsonArray(value) - } else { - return [] - } - - return isPartial ? dropPotentiallyIncompleteTail(parsedItems) : parsedItems -} - -export function parseNewBlankCanvasFlag(value: unknown, isPartial: boolean): boolean | null { - if (typeof value === 'boolean') return value - if (typeof value === 'number') return value !== 0 - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase() - if (normalized.length === 0) return null - if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true - if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false - if (isPartial) { - if ('true'.startsWith(normalized)) return true - if ('false'.startsWith(normalized)) return false - } - } - return null -} - -export function toCreatePreviewShapes( - value: unknown, - isPartial: boolean -): { shapes: TLShape[]; bindings: TLBindingCreate[] } { - const candidateItems = parsePreviewArray(value, isPartial) - const validShapes: FocusedShape[] = [] - for (const item of candidateItems) { - const parsed = FocusedShapeSchema.safeParse(item) - if (parsed.success) validShapes.push(parsed.data) - } - return convertFocusedShapesToTldrawRecords(validShapes) -} - -export function toUpdatePreviewShapes( - value: unknown, - isPartial: boolean, - baseShapes: TLShape[] -): { shapes: TLShape[]; bindings: TLBindingCreate[] } { - const candidateItems = parsePreviewArray(value, isPartial) - if (candidateItems.length <= 0) return { shapes: [], bindings: [] } - - const baseShapesById = new Map() - for (const shape of baseShapes) { - baseShapesById.set(shape.id, shape) - } - - const previewShapes: TLShape[] = [] - const previewBindings: TLBindingCreate[] = [] - for (const item of candidateItems) { - const parsedUpdate = FocusedShapeUpdateSchema.safeParse(item) - if (!parsedUpdate.success) continue - - const update = parsedUpdate.data - const existingShape = baseShapesById.get(normalizeShapeId(update.shapeId)) - if (!existingShape) continue - - try { - const existingFocused = convertTldrawRecordToFocusedShape(existingShape) - const merged = deepMerge(existingFocused, { - ...update, - shapeId: toSimpleShapeId(update.shapeId), - _type: update._type ?? existingFocused._type, - }) as FocusedShape - const result = convertFocusedShapeToTldrawRecord(merged) - previewShapes.push(result.shape) - previewBindings.push(...result.bindings) - } catch { - // Ignore unsupported update previews. - } - } - - return { shapes: previewShapes, bindings: previewBindings } -} - -export function toDeletePreviewSnapshot( - value: unknown, - isPartial: boolean, - committed: CanvasSnapshot -): CanvasSnapshot | null { - const candidateItems = parsePreviewArray(value, isPartial) - const shapeIds = candidateItems.filter((item): item is string => typeof item === 'string') - if (shapeIds.length <= 0) return null - - const idsToDelete = new Set(shapeIds.map((shapeId) => normalizeShapeId(shapeId))) - const filteredShapes = committed.shapes.filter((shape) => !idsToDelete.has(shape.id)) - if (filteredShapes.length === committed.shapes.length) return null - - return { - shapes: filteredShapes.map((shape) => structuredClone(shape)), - assets: [], - } -} - -export function mergeShapesById(base: TLShape[], additions: TLShape[]): TLShape[] { - const merged = new Map() - for (const shape of base) { - merged.set(shape.id, structuredClone(shape)) - } - for (const shape of additions) { - merged.set(shape.id, structuredClone(shape)) - } - return [...merged.values()] -} diff --git a/apps/mcp-app/src/worker.ts b/apps/mcp-app/src/worker.ts index 151b842dd257..23218521c0ef 100644 --- a/apps/mcp-app/src/worker.ts +++ b/apps/mcp-app/src/worker.ts @@ -9,9 +9,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpAgent } from 'agents/mcp' -import type { TLShape } from 'tldraw' import { Logger } from './logger' import { registerTools } from './register-tools' +import { loadEditorApiSpecFromAssets, loadMethodMapFromAssets } from './shared/generated-data' +import { PendingRequests } from './shared/pending-requests' import { MAX_CHECKPOINTS, MCP_SERVER_DESCRIPTION, @@ -21,14 +22,15 @@ import { MCP_SERVER_VERSION, MCP_SERVER_WEBSITE_URL, } from './shared/types' -import type { MCP_APP_HOST_NAMES, ServerDeps } from './shared/types' -import { parseTlShapes, resolveMcpAppHostNameFromServerInfo } from './shared/utils' +import type { MCP_APP_HOST_NAMES, PendingBootstrap, ServerDeps } from './shared/types' +import { resolveMcpAppHostNameFromServerInfo } from './shared/utils' // --- Types --- interface Env { MCP_OBJECT: DurableObjectNamespace ASSETS: Fetcher + LOADER: WorkerLoader RATE_LIMITER: RateLimit MCP_AUTH_TOKEN: string MCP_IS_DEV: string @@ -81,6 +83,13 @@ export class TldrawMCP extends McpAgent { sessionId: string = '' logger = new Logger('TldrawMCP', this.logsEnabled) clientHostName: MCP_APP_HOST_NAMES | undefined = undefined + pendingRequests = new PendingRequests() + pendingBootstrap: PendingBootstrap | null = null + + /** The MCP session ID used for DO routing (extracted from DO name). */ + getMcpSessionId(): string { + return (this as any).name?.replace(/^streamable-http:/, '') ?? '' + } async init() { this.server.server.oninitialized = () => { @@ -98,6 +107,8 @@ export class TldrawMCP extends McpAgent { void this .sql`CREATE TABLE IF NOT EXISTS checkpoints (id TEXT PRIMARY KEY, data TEXT, created_at INTEGER)` void this.sql`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)` + void this + .sql`CREATE TABLE IF NOT EXISTS canvas_checkpoints (canvas_id TEXT PRIMARY KEY, checkpoint_id TEXT)` // Restore active checkpoint on reconnect const rows = [...this.sql`SELECT value FROM meta WHERE key = 'activeCheckpointId'`] @@ -131,22 +142,48 @@ export class TldrawMCP extends McpAgent { // --- Widget HTML (loaded once from Assets binding) --- const widgetHtml = await loadWidgetHtml(this.env.ASSETS) + let editorApiSpecPromise: ReturnType | null = null + let methodMapPromise: ReturnType | null = null // --- Build ServerDeps from SQLite --- const deps: ServerDeps = { saveCheckpoint: (id, shapes, assets = [], bindings = []) => this.saveCheckpoint(id, shapes, assets, bindings), loadCheckpoint: (id) => this.loadCheckpoint(id), - getActiveShapes: () => this.getActiveShapes(), - getActiveAssets: () => this.getActiveAssets(), - getActiveBindings: () => this.getActiveBindings(), getActiveCheckpointId: () => this.activeCheckpointId, setActiveCheckpointId: (id) => { this.activeCheckpointId = id void this.sql`INSERT OR REPLACE INTO meta (key, value) VALUES ('activeCheckpointId', ${id})` }, + getCanvasCheckpointId: (canvasId) => { + const rows = [ + ...this.sql`SELECT checkpoint_id FROM canvas_checkpoints WHERE canvas_id = ${canvasId}`, + ] + return rows.length > 0 ? (rows[0].checkpoint_id as string) : null + }, + setCanvasCheckpointId: (canvasId, checkpointId) => { + void this + .sql`INSERT OR REPLACE INTO canvas_checkpoints (canvas_id, checkpoint_id) VALUES (${canvasId}, ${checkpointId})` + }, + setPendingBootstrap: (bootstrap) => { + this.pendingBootstrap = bootstrap + }, + consumePendingBootstrap: () => { + const b = this.pendingBootstrap + this.pendingBootstrap = null + return b + }, getSessionId: () => this.sessionId, + getMcpSessionId: () => this.getMcpSessionId(), loadWidgetHtml: async () => widgetHtml, + loadEditorApiSpec: async () => { + editorApiSpecPromise ??= loadEditorApiSpecFromAssets(this.env.ASSETS) + return editorApiSpecPromise + }, + loadMethodMap: async () => { + methodMapPromise ??= loadMethodMapFromAssets(this.env.ASSETS) + return methodMapPromise + }, } const workerOrigin = this.env.WORKER_ORIGIN @@ -155,10 +192,12 @@ export class TldrawMCP extends McpAgent { log: this.logger.toLogFn(), extraResourceDomains: workerOrigin ? [workerOrigin] : [], extraConnectDomains: workerOrigin ? [workerOrigin] : [], + searchWorkerLoader: this.env.LOADER, workerOrigin, isDev: this.isDev, analytics: this.env.MCP_ANALYTICS, getClientHostName: () => this.clientHostName, + pendingRequests: this.pendingRequests, }) } @@ -190,24 +229,6 @@ export class TldrawMCP extends McpAgent { bindings: parsed.bindings ?? [], } } - - getActiveShapes(): TLShape[] { - if (!this.activeCheckpointId) return [] - const checkpoint = this.loadCheckpoint(this.activeCheckpointId) - return checkpoint ? parseTlShapes(checkpoint.shapes) : [] - } - - getActiveAssets(): unknown[] { - if (!this.activeCheckpointId) return [] - const checkpoint = this.loadCheckpoint(this.activeCheckpointId) - return checkpoint ? checkpoint.assets : [] - } - - getActiveBindings(): unknown[] { - if (!this.activeCheckpointId) return [] - const checkpoint = this.loadCheckpoint(this.activeCheckpointId) - return checkpoint ? checkpoint.bindings : [] - } } // --- Fetch handler --- @@ -255,17 +276,22 @@ export default { // Streamable HTTP transport if (url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) { - // Rate limit by MCP session (POST without session ID is the initial handshake) const sessionId = request.headers.get('mcp-session-id') - if (!sessionId && request.method !== 'POST') { - return corsResponse(new Response('Missing session', { status: 400 })) + const forwardedFor = request.headers.get('x-forwarded-for') + const clientIp = + request.headers.get('cf-connecting-ip') ?? forwardedFor?.split(',')[0]?.trim() + const rateLimitKey = sessionId + ? `mcp-session:${sessionId}` + : `mcp-ip:${clientIp ?? 'unknown'}` + + const { success } = await env.RATE_LIMITER.limit({ key: rateLimitKey }) + if (!success) { + return corsResponse(new Response('Rate limited', { status: 429 })) } - if (sessionId) { - const { success } = await env.RATE_LIMITER.limit({ key: sessionId }) - if (!success) { - return corsResponse(new Response('Rate limited', { status: 429 })) - } + // POST without a session ID is the initial handshake. + if (!sessionId && request.method !== 'POST') { + return corsResponse(new Response('Missing session', { status: 400 })) } return mcpHandler.fetch(request, env, ctx) } diff --git a/apps/mcp-app/tsconfig.json b/apps/mcp-app/tsconfig.json index 6178dea5db01..8e5f7fdad451 100644 --- a/apps/mcp-app/tsconfig.json +++ b/apps/mcp-app/tsconfig.json @@ -13,7 +13,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "server.ts", "main.ts", "vite.config.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"], "exclude": ["node_modules", "dist"], "references": [ { diff --git a/apps/mcp-app/wrangler.toml b/apps/mcp-app/wrangler.toml index 4322aadafb12..6c4fc0680090 100644 --- a/apps/mcp-app/wrangler.toml +++ b/apps/mcp-app/wrangler.toml @@ -1,5 +1,6 @@ name = "tldraw-mcp-app" main = "src/worker.ts" +# Keep in sync with WORKER_COMPATIBILITY_DATE in src/shared/types.ts compatibility_date = "2025-03-10" compatibility_flags = ["nodejs_compat"] preview_urls = true @@ -28,6 +29,9 @@ MCP_IS_DEV = "false" binding = "MCP_ANALYTICS" dataset = "MCP_ANALYTICS" +[[worker_loaders]] +binding = "LOADER" + #################### Rate limiting #################### [[ratelimits]] name = "RATE_LIMITER"