pi-tldraw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/bridge/app-bridge-entry.js +6 -0
  4. package/mcp-app/LICENSE.md +9 -0
  5. package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
  6. package/mcp-app/README.md +129 -0
  7. package/mcp-app/dev-tunnel.sh +51 -0
  8. package/mcp-app/dist/editor-api.json +8493 -0
  9. package/mcp-app/dist/mcp-app.html +643 -0
  10. package/mcp-app/dist/method-map.json +915 -0
  11. package/mcp-app/package.json +42 -0
  12. package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
  13. package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
  14. package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
  15. package/mcp-app/scripts/extract-editor-api.ts +1374 -0
  16. package/mcp-app/server.json +21 -0
  17. package/mcp-app/src/logger.ts +45 -0
  18. package/mcp-app/src/register-tools.ts +368 -0
  19. package/mcp-app/src/shared/generated-data.ts +160 -0
  20. package/mcp-app/src/shared/pending-requests.ts +69 -0
  21. package/mcp-app/src/shared/types.ts +76 -0
  22. package/mcp-app/src/shared/utils.ts +132 -0
  23. package/mcp-app/src/tools/exec.ts +120 -0
  24. package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
  25. package/mcp-app/src/tools/search.ts +150 -0
  26. package/mcp-app/src/widget/app-context.tsx +29 -0
  27. package/mcp-app/src/widget/dev-log.tsx +70 -0
  28. package/mcp-app/src/widget/exec-helpers.ts +232 -0
  29. package/mcp-app/src/widget/export-tldr.ts +35 -0
  30. package/mcp-app/src/widget/focused/defaults.ts +141 -0
  31. package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
  32. package/mcp-app/src/widget/focused/format.ts +366 -0
  33. package/mcp-app/src/widget/focused/to-focused.ts +258 -0
  34. package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
  35. package/mcp-app/src/widget/image-guard.tsx +106 -0
  36. package/mcp-app/src/widget/index.html +33 -0
  37. package/mcp-app/src/widget/mcp-app.css +113 -0
  38. package/mcp-app/src/widget/mcp-app.tsx +857 -0
  39. package/mcp-app/src/widget/persistence.ts +337 -0
  40. package/mcp-app/src/widget/snapshot.ts +157 -0
  41. package/mcp-app/src/worker.ts +305 -0
  42. package/mcp-app/tsconfig.json +23 -0
  43. package/mcp-app/vite.config.ts +13 -0
  44. package/mcp-app/wrangler.toml +45 -0
  45. package/mcp-app-source.json +36 -0
  46. package/package.json +90 -0
  47. package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
  48. package/scripts/assemble-mcp-app.mjs +193 -0
  49. package/scripts/build-bridge.mjs +74 -0
  50. package/scripts/e2e-mcp.mjs +69 -0
  51. package/scripts/e2e-packaged-mcp-app.mjs +79 -0
  52. package/scripts/run-mcp-app-dev.mjs +44 -0
  53. package/scripts/verify-bundle.mjs +41 -0
  54. package/scripts/verify-mcp-app-source.mjs +51 -0
  55. package/scripts/verify-mcp-app.mjs +38 -0
  56. package/scripts/verify-package-files.mjs +50 -0
  57. package/src/canvas/export.ts +164 -0
  58. package/src/canvas/state.ts +117 -0
  59. package/src/canvas/workflow.ts +105 -0
  60. package/src/commands/tldraw-command.ts +48 -0
  61. package/src/diagram/guidance.ts +44 -0
  62. package/src/host/local-host.ts +289 -0
  63. package/src/index.ts +762 -0
  64. package/src/mcp/client.ts +126 -0
  65. package/src/mcp/response.ts +74 -0
  66. package/src/semantic/layer.ts +309 -0
  67. package/src/server/server-manager.ts +153 -0
  68. package/src/store/export-store.ts +33 -0
  69. package/src/store/project-store.ts +251 -0
  70. package/src/ui/tldraw-status.ts +88 -0
  71. package/static/app-bridge-bundle.js +18114 -0
  72. package/static/app-bridge-bundle.meta.json +164 -0
  73. package/static/host.html +390 -0
  74. package/tsconfig.json +13 -0
@@ -0,0 +1,857 @@
1
+ import { type App, McpUiDisplayMode, useApp } from '@modelcontextprotocol/ext-apps/react'
2
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import {
5
+ type TLAsset,
6
+ type TLBindingCreate,
7
+ type TLComponents,
8
+ type TLShapeId,
9
+ type TLUiEventHandler,
10
+ DefaultToolbar,
11
+ DefaultToolbarContent,
12
+ Editor,
13
+ Tldraw,
14
+ TldrawUiIcon,
15
+ useEditor,
16
+ useValue,
17
+ } from 'tldraw'
18
+ import 'tldraw/tldraw.css'
19
+ import './mcp-app.css'
20
+ import tldrawLogoUrl from '../../plugins/tldraw-mcp/assets/logo.svg'
21
+ import { primeEmbeddedMethodMap } from '../shared/generated-data'
22
+ import {
23
+ MCP_SERVER_DESCRIPTION,
24
+ MCP_SERVER_NAME,
25
+ MCP_SERVER_TITLE,
26
+ MCP_SERVER_VERSION,
27
+ MCP_SERVER_WEBSITE_URL,
28
+ } from '../shared/types'
29
+ import type { MCP_APP_HOST_NAMES } from '../shared/types'
30
+ import { isHostCodeEditor, resolveMcpAppHostNameFromClientInfo } from '../shared/utils'
31
+ import { McpAppContext } from './app-context'
32
+ import { DEV_LOG_PANEL_HEIGHT, DevLogPanel, useDevLog } from './dev-log'
33
+ import { executeCode } from './exec-helpers'
34
+ import { exportTldr } from './export-tldr'
35
+ import { ImageDropGuard, uiOverrides } from './image-guard'
36
+ import {
37
+ type CanvasSnapshot,
38
+ getEmbeddedBootstrap,
39
+ getLatestCheckpointSnapshot,
40
+ loadLocalSnapshot,
41
+ parseCheckpointFromToolResult,
42
+ clearCanvasContext,
43
+ pushCanvasContext,
44
+ saveCheckpointToServer,
45
+ saveLocalSnapshot,
46
+ setCurrentCanvasId,
47
+ setCurrentSessionId,
48
+ syncEditorState,
49
+ } from './persistence'
50
+ import { applySnapshot, zoomToFitRequestShapes } from './snapshot'
51
+
52
+ const LICENSE_KEY = import.meta.env.VITE_TLDRAW_LICENSE_KEY as string
53
+
54
+ const SAVE_DEBOUNCE_MS = 500
55
+
56
+ function SharePanelContent() {
57
+ const editor = useEditor()
58
+ const hasShapes = useValue('hasShapes', () => editor.getCurrentPageShapeIds().size > 0, [editor])
59
+
60
+ const {
61
+ displayMode,
62
+ toggleFullscreen,
63
+ canFullscreen,
64
+ canDownload,
65
+ app,
66
+ lastEditor,
67
+ hostName,
68
+ isDev,
69
+ isDevLogVisible,
70
+ toggleDevLog,
71
+ } = useContext(McpAppContext)
72
+
73
+ const isCodeEditor = useMemo(() => {
74
+ if (!hostName) return false
75
+ return isHostCodeEditor(hostName)
76
+ }, [hostName])
77
+
78
+ const handleBuildItClick = useCallback(() => {
79
+ if (!app) return
80
+ const messageText =
81
+ lastEditor === 'user'
82
+ ? "Hey I've made some edits to the canvas. The new canvas state is attached. Take the changes and implement them in the codebase."
83
+ : 'Use the attached canvas state to implement this in the codebase.'
84
+ app.sendMessage({
85
+ role: 'user',
86
+ content: [
87
+ {
88
+ type: 'text',
89
+ text: messageText,
90
+ },
91
+ ],
92
+ })
93
+ }, [app, lastEditor])
94
+
95
+ return (
96
+ <div className="tlui-share-zone mcp-app__share-zone" draggable={false}>
97
+ {canDownload && (
98
+ <button
99
+ className="tlui-button tlui-button__low"
100
+ onClick={() => exportTldr(editor, app ?? undefined)}
101
+ title="Copy to clipboard and download .tldr file"
102
+ >
103
+ <TldrawUiIcon label="Download .tldr file" icon="download" />
104
+ </button>
105
+ )}
106
+ {toggleFullscreen && canFullscreen && (
107
+ <button
108
+ className="tlui-button tlui-button__low"
109
+ onClick={toggleFullscreen}
110
+ title={displayMode === 'fullscreen' ? 'Exit fullscreen' : 'Enter fullscreen'}
111
+ >
112
+ {displayMode === 'fullscreen' ? 'Exit fullscreen' : 'Fullscreen'}
113
+ </button>
114
+ )}
115
+ {isDev && toggleDevLog && (
116
+ <button
117
+ className="tlui-button tlui-button__low"
118
+ onClick={toggleDevLog}
119
+ title={isDevLogVisible ? 'Hide dev log' : 'Show dev log'}
120
+ >
121
+ {isDevLogVisible ? 'Hide dev log' : 'Show dev log'}
122
+ </button>
123
+ )}
124
+ {isCodeEditor && app && (
125
+ <button
126
+ onClick={handleBuildItClick}
127
+ disabled={!hasShapes}
128
+ title="Build it"
129
+ className={`mcp-app__build-button${hasShapes ? ' mcp-app__build-button--enabled' : ''}`}
130
+ >
131
+ Build it
132
+ </button>
133
+ )}
134
+ </div>
135
+ )
136
+ }
137
+
138
+ function DynamicToolbar() {
139
+ const { displayMode } = useContext(McpAppContext)
140
+ return (
141
+ <DefaultToolbar orientation={displayMode === 'fullscreen' ? 'vertical' : 'horizontal'}>
142
+ <DefaultToolbarContent />
143
+ </DefaultToolbar>
144
+ )
145
+ }
146
+
147
+ const tldrawComponents: TLComponents = {
148
+ SharePanel: SharePanelContent,
149
+ Toolbar: DynamicToolbar,
150
+ }
151
+
152
+ const ERROR_BANNER_HEIGHT = 30
153
+
154
+ function parseHostTheme(value: unknown): 'light' | 'dark' | null {
155
+ return value === 'dark' || value === 'light' ? value : null
156
+ }
157
+
158
+ function TldrawCanvas({ app }: { app: App }) {
159
+ const [displayMode, setDisplayMode] =
160
+ useState<Extract<McpUiDisplayMode, 'inline' | 'fullscreen'>>('inline')
161
+ const [containerHeight, setContainerHeight] = useState<number | null>(null)
162
+ const [lastEditor, setLastEditor] = useState<'user' | 'ai'>('ai')
163
+ const [hostContext, setHostContext] = useState(() => app.getHostContext())
164
+ const [hostTheme, setHostTheme] = useState<'light' | 'dark'>(() => {
165
+ const initialTheme = parseHostTheme(
166
+ (app.getHostContext() as { theme?: string } | undefined)?.theme
167
+ )
168
+ return initialTheme ?? 'light'
169
+ })
170
+ const [canvasTheme, setCanvasTheme] = useState<'light' | 'dark'>(hostTheme)
171
+ const [execError, setExecError] = useState<string | null>(null)
172
+ const editorRef = useRef<Editor | null>(null)
173
+
174
+ const pendingSnapshotRef = useRef<CanvasSnapshot | null>(null)
175
+ const committedSnapshotRef = useRef<CanvasSnapshot>({ shapes: [], assets: [] })
176
+ const checkpointIdRef = useRef<string | null>(null)
177
+ const removeStoreListenerRef = useRef<(() => void) | null>(null)
178
+ const editorReadyResolveRef = useRef<((editor: Editor) => void) | null>(null)
179
+ const editorReadyPromiseRef = useRef<Promise<Editor> | null>(null)
180
+ const saveTimerRef = useRef<number | null>(null)
181
+ const hasUserEditedSinceAiRef = useRef(false)
182
+ const lastEditorRef = useRef<'user' | 'ai'>('ai')
183
+ const execPartialDebounceRef = useRef<number | null>(null)
184
+ const hasExecRunRef = useRef(false)
185
+
186
+ const { isDev, isDevLogVisible, devLogEntries, logIfDevMode, toggleDevLog, enableDevMode } =
187
+ useDevLog()
188
+
189
+ const hostCapabilities = useMemo(() => {
190
+ return app.getHostCapabilities()
191
+ }, [app])
192
+
193
+ const hostInfo = useMemo(() => {
194
+ return app.getHostVersion()
195
+ }, [app])
196
+
197
+ const isMobilePlatform = hostContext?.platform === 'mobile'
198
+ const isDarkTheme = canvasTheme === 'dark'
199
+
200
+ const syncThemeFromEditor = useCallback(() => {
201
+ const editor = editorRef.current
202
+ if (!editor) return
203
+ setCanvasTheme(editor.user.getIsDarkMode() ? 'dark' : 'light')
204
+ }, [])
205
+
206
+ const applyHostThemeToEditor = useCallback((theme: 'light' | 'dark') => {
207
+ setHostTheme(theme)
208
+ setCanvasTheme(theme)
209
+ const editor = editorRef.current
210
+ if (!editor) return
211
+ editor.user.updateUserPreferences({ colorScheme: theme })
212
+ }, [])
213
+
214
+ const handleUiEvent = useCallback<TLUiEventHandler>(
215
+ (name) => {
216
+ const eventName = name as string
217
+ if (eventName !== 'toggle-dark-mode' && eventName !== 'color-scheme') return
218
+ queueMicrotask(() => {
219
+ syncThemeFromEditor()
220
+ })
221
+ },
222
+ [syncThemeFromEditor]
223
+ )
224
+
225
+ const canFullscreen = useMemo(() => {
226
+ if (isMobilePlatform) return false
227
+ const modes = hostContext?.availableDisplayModes
228
+ if (!modes) return false
229
+ return modes.includes('fullscreen')
230
+ }, [hostContext, isMobilePlatform])
231
+
232
+ const canDownload = useMemo(() => {
233
+ return !!hostCapabilities?.downloadFile
234
+ }, [hostCapabilities])
235
+
236
+ const [hostName, setHostName] = useState<MCP_APP_HOST_NAMES | null>(null)
237
+ const devLogPanelHeight = isDev && isDevLogVisible ? DEV_LOG_PANEL_HEIGHT : 0
238
+
239
+ useEffect(() => {
240
+ const resolved = resolveMcpAppHostNameFromClientInfo(hostInfo?.name ?? '')
241
+ if (resolved) {
242
+ setHostName(resolved)
243
+ }
244
+ }, [hostInfo])
245
+
246
+ const teardownEditor = useCallback(() => {
247
+ removeStoreListenerRef.current?.()
248
+ removeStoreListenerRef.current = null
249
+ if (saveTimerRef.current !== null) {
250
+ window.clearTimeout(saveTimerRef.current)
251
+ saveTimerRef.current = null
252
+ }
253
+ if (execPartialDebounceRef.current !== null) {
254
+ window.clearTimeout(execPartialDebounceRef.current)
255
+ execPartialDebounceRef.current = null
256
+ }
257
+ editorRef.current?.dispose()
258
+ editorRef.current = null
259
+ // Reset the editor-ready promise so waitForEditor() creates a fresh one
260
+ // that will be resolved by the next handleMount().
261
+ editorReadyResolveRef.current = null
262
+ editorReadyPromiseRef.current = null
263
+ }, [])
264
+
265
+ const markAiActivity = useCallback(() => {
266
+ hasUserEditedSinceAiRef.current = false
267
+ if (lastEditorRef.current !== 'ai') {
268
+ lastEditorRef.current = 'ai'
269
+ setLastEditor('ai')
270
+ }
271
+ }, [])
272
+
273
+ const markUserEdit = useCallback(() => {
274
+ hasUserEditedSinceAiRef.current = true
275
+ if (lastEditorRef.current !== 'user') {
276
+ lastEditorRef.current = 'user'
277
+ setLastEditor('user')
278
+ }
279
+ }, [])
280
+
281
+ const toggleFullscreen = useCallback(async () => {
282
+ const newMode = displayMode === 'fullscreen' ? 'inline' : 'fullscreen'
283
+ if (newMode === 'fullscreen' && (isMobilePlatform || !canFullscreen)) {
284
+ return
285
+ }
286
+
287
+ if (newMode === 'inline') {
288
+ const editor = editorRef.current
289
+ if (editor) {
290
+ committedSnapshotRef.current = syncEditorState(app, editor, checkpointIdRef.current)
291
+ }
292
+ }
293
+
294
+ try {
295
+ const result = await app.requestDisplayMode({ mode: newMode })
296
+ const actualMode = result.mode === 'fullscreen' ? 'fullscreen' : 'inline'
297
+ setDisplayMode(actualMode)
298
+ } catch {
299
+ return
300
+ }
301
+ }, [app, canFullscreen, displayMode, isMobilePlatform])
302
+
303
+ const mcpAppCtx = useMemo(
304
+ () => ({
305
+ displayMode,
306
+ toggleFullscreen,
307
+ canFullscreen,
308
+ canDownload,
309
+ app,
310
+ lastEditor,
311
+ hostName,
312
+ isDev,
313
+ isDevLogVisible,
314
+ toggleDevLog,
315
+ logIfDevMode,
316
+ }),
317
+ [
318
+ displayMode,
319
+ toggleFullscreen,
320
+ canFullscreen,
321
+ canDownload,
322
+ app,
323
+ lastEditor,
324
+ hostName,
325
+ isDev,
326
+ isDevLogVisible,
327
+ toggleDevLog,
328
+ logIfDevMode,
329
+ ]
330
+ )
331
+
332
+ /** Returns the editor, waiting for mount if it hasn't happened yet. */
333
+ const waitForEditor = useCallback((): Promise<Editor> => {
334
+ const editor = editorRef.current
335
+ if (editor) return Promise.resolve(editor)
336
+
337
+ if (editorReadyPromiseRef.current) return editorReadyPromiseRef.current
338
+
339
+ editorReadyPromiseRef.current = new Promise<Editor>((resolve) => {
340
+ editorReadyResolveRef.current = resolve
341
+ })
342
+ return editorReadyPromiseRef.current
343
+ }, [])
344
+
345
+ const scheduleSave = useCallback(() => {
346
+ if (saveTimerRef.current !== null) {
347
+ window.clearTimeout(saveTimerRef.current)
348
+ }
349
+ saveTimerRef.current = window.setTimeout(() => {
350
+ saveTimerRef.current = null
351
+ const editor = editorRef.current
352
+ if (!editor) return
353
+ syncEditorState(app, editor, checkpointIdRef.current)
354
+ }, SAVE_DEBOUNCE_MS)
355
+ }, [app])
356
+
357
+ useEffect(() => {
358
+ setHostContext(app.getHostContext())
359
+ const initialTheme = parseHostTheme(
360
+ (app.getHostContext() as { theme?: string } | undefined)?.theme
361
+ )
362
+ if (initialTheme) {
363
+ applyHostThemeToEditor(initialTheme)
364
+ }
365
+
366
+ logIfDevMode('Bootstrap loading...')
367
+ const bootstrap = getEmbeddedBootstrap()
368
+ primeEmbeddedMethodMap()
369
+
370
+ // Delete the bootstrap data from the window object to prevent it from being used again.
371
+ delete window.__TLDRAW_BOOTSTRAP__
372
+
373
+ if (bootstrap) {
374
+ setCurrentSessionId(bootstrap.sessionId)
375
+ if (bootstrap.canvasId) {
376
+ setCurrentCanvasId(bootstrap.canvasId)
377
+ }
378
+ if (bootstrap.isDev) {
379
+ enableDevMode()
380
+ }
381
+ logIfDevMode(
382
+ `Bootstrap loaded for session ${bootstrap.sessionId}, canvas ${bootstrap.canvasId ?? 'none'}${bootstrap.isDev ? ' (dev mode)' : ''}`
383
+ )
384
+
385
+ if (bootstrap.snapshot) {
386
+ if (committedSnapshotRef.current.shapes.length === 0) {
387
+ const snapshot: CanvasSnapshot = {
388
+ shapes: bootstrap.snapshot.shapes,
389
+ assets: bootstrap.snapshot.assets,
390
+ bindings: bootstrap.snapshot.bindings,
391
+ }
392
+ committedSnapshotRef.current = snapshot
393
+ if (bootstrap.checkpointId) {
394
+ checkpointIdRef.current = bootstrap.checkpointId
395
+ logIfDevMode(
396
+ `Restored embedded checkpoint ${bootstrap.checkpointId} with ${bootstrap.snapshot.shapes.length} shape(s)`
397
+ )
398
+ }
399
+ const editor = editorRef.current
400
+ if (editor) {
401
+ applySnapshot(editor, snapshot)
402
+ } else {
403
+ pendingSnapshotRef.current = snapshot
404
+ }
405
+ }
406
+ } else {
407
+ const latestSnapshot = getLatestCheckpointSnapshot()
408
+ if (latestSnapshot && committedSnapshotRef.current.shapes.length === 0) {
409
+ logIfDevMode(
410
+ `Restored latest local snapshot with ${latestSnapshot.shapes.length} shape(s)`
411
+ )
412
+ committedSnapshotRef.current = latestSnapshot
413
+ const editor = editorRef.current
414
+ if (editor) {
415
+ applySnapshot(editor, latestSnapshot)
416
+ } else {
417
+ pendingSnapshotRef.current = latestSnapshot
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ app.onhostcontextchanged = (ctx) => {
424
+ const nextContext = app.getHostContext() ?? ctx
425
+ setHostContext(nextContext)
426
+ const nextTheme = parseHostTheme(
427
+ (ctx as { theme?: string } | undefined)?.theme ??
428
+ (nextContext as { theme?: string } | undefined)?.theme
429
+ )
430
+ if (nextTheme) {
431
+ // Host theme changes take precedence over local preference changes.
432
+ applyHostThemeToEditor(nextTheme)
433
+ }
434
+
435
+ const dims = ctx.containerDimensions
436
+ if (dims && 'height' in dims) {
437
+ setContainerHeight(dims.height)
438
+ }
439
+
440
+ if (ctx.displayMode !== undefined) {
441
+ const newMode = ctx.displayMode === 'fullscreen' ? 'fullscreen' : 'inline'
442
+
443
+ if (newMode !== 'fullscreen') {
444
+ const editor = editorRef.current
445
+ if (editor) {
446
+ committedSnapshotRef.current = syncEditorState(app, editor, checkpointIdRef.current)
447
+ }
448
+ }
449
+
450
+ setDisplayMode(newMode)
451
+ }
452
+ }
453
+
454
+ const runExec = (code: string, source: string, canvasId?: string) => {
455
+ if (hasExecRunRef.current) {
456
+ logIfDevMode(`Exec: skipping duplicate exec from ${source}`)
457
+ return
458
+ }
459
+ hasExecRunRef.current = true
460
+
461
+ logIfDevMode(`Exec: running from ${source}`)
462
+ markAiActivity()
463
+
464
+ void (async () => {
465
+ logIfDevMode('Exec: waiting for editor...')
466
+ const editor = await waitForEditor()
467
+
468
+ if (canvasId) {
469
+ setCurrentCanvasId(canvasId)
470
+
471
+ if (editor.getCurrentPageShapeIds().size === 0) {
472
+ logIfDevMode(`Exec: canvas empty, fetching state for canvasId=${canvasId}`)
473
+ try {
474
+ const response = await app.callServerTool({
475
+ name: '_get_canvas_state',
476
+ arguments: { canvasId },
477
+ })
478
+ const res = response as any
479
+ let data: any = null
480
+ // Try structuredContent first, fall back to parsing text content
481
+ if (res?.structuredContent) {
482
+ data = res.structuredContent
483
+ } else if (Array.isArray(res?.content)) {
484
+ const textItem = res.content.find(
485
+ (c: any) => c.type === 'text' && typeof c.text === 'string'
486
+ )
487
+ if (textItem) {
488
+ try {
489
+ data = JSON.parse(textItem.text)
490
+ } catch {
491
+ // not JSON
492
+ }
493
+ }
494
+ }
495
+ if (data && Array.isArray(data.shapes) && data.shapes.length > 0) {
496
+ const snapshot: CanvasSnapshot = {
497
+ shapes: data.shapes,
498
+ assets: Array.isArray(data.assets) ? data.assets : [],
499
+ bindings: Array.isArray(data.bindings) ? data.bindings : [],
500
+ }
501
+ applySnapshot(editor, snapshot)
502
+ committedSnapshotRef.current = snapshot
503
+ if (typeof data.checkpointId === 'string') {
504
+ checkpointIdRef.current = data.checkpointId
505
+ }
506
+ logIfDevMode(
507
+ `Exec: restored ${data.shapes.length} shape(s) from server for canvasId=${canvasId}`
508
+ )
509
+ } else {
510
+ logIfDevMode(`Exec: no shapes returned from server for canvasId=${canvasId}`)
511
+ }
512
+ } catch (err) {
513
+ logIfDevMode(`Exec: failed to fetch canvas state: ${err}`)
514
+ }
515
+ }
516
+ }
517
+
518
+ logIfDevMode('Exec: editor ready, executing code')
519
+
520
+ const execResult = await executeCode(editor, code)
521
+ logIfDevMode(
522
+ `Exec ${execResult.success ? 'succeeded' : 'failed'}: ${JSON.stringify(execResult.result ?? execResult.error)}`
523
+ )
524
+
525
+ // Call _exec_callback FIRST to get the server-assigned canvasId
526
+ const callbackArgs = execResult.success
527
+ ? { channel: 'exec', result: { success: true, result: execResult.result } }
528
+ : { channel: 'exec', result: { success: false, error: execResult.error } }
529
+ try {
530
+ const cbResponse = await app.callServerTool({
531
+ name: '_exec_callback',
532
+ arguments: callbackArgs,
533
+ })
534
+ const cbRes = cbResponse as any
535
+ let cbData: any = null
536
+ if (Array.isArray(cbRes?.content)) {
537
+ const textItem = cbRes.content.find(
538
+ (c: any) => c.type === 'text' && typeof c.text === 'string'
539
+ )
540
+ if (textItem) {
541
+ try {
542
+ cbData = JSON.parse(textItem.text)
543
+ } catch {
544
+ // not JSON
545
+ }
546
+ }
547
+ }
548
+ if (cbData?.canvasId) {
549
+ setCurrentCanvasId(cbData.canvasId)
550
+ logIfDevMode(`Exec: server canvasId=${cbData.canvasId}`)
551
+ }
552
+ logIfDevMode('Exec: _exec_callback succeeded')
553
+ } catch (err) {
554
+ logIfDevMode(`Exec: _exec_callback failed: ${err}`)
555
+ }
556
+
557
+ if (execResult.success) {
558
+ const cpId = checkpointIdRef.current ?? crypto.randomUUID()
559
+ checkpointIdRef.current = cpId
560
+
561
+ const resultStr =
562
+ execResult.result !== undefined ? JSON.stringify(execResult.result, null, 2) : undefined
563
+ committedSnapshotRef.current = syncEditorState(app, editor, cpId, {
564
+ message: resultStr
565
+ ? `Code executed successfully on canvas. Return value:\n${resultStr}`
566
+ : 'Code executed successfully on canvas.',
567
+ })
568
+
569
+ const snapshot = committedSnapshotRef.current
570
+ const allShapeIds = new Set(snapshot.shapes.map((s) => s.id))
571
+ zoomToFitRequestShapes(editor, allShapeIds)
572
+ } else {
573
+ clearCanvasContext(app, {
574
+ message:
575
+ 'Canvas context was cleared because code execution failed. Fix the error before using the canvas context again.',
576
+ })
577
+ teardownEditor()
578
+ setExecError(execResult.error ?? 'Unknown error')
579
+ void app.sendSizeChanged({ width: 400, height: ERROR_BANNER_HEIGHT })
580
+ }
581
+ })()
582
+ }
583
+
584
+ app.ontoolinput = (params) => {
585
+ logIfDevMode('Exec: ontoolinput called')
586
+ // Clear any previous exec error so the Tldraw component remounts and
587
+ // the editor becomes available again. Without this, a single runtime
588
+ // error permanently bricks the canvas — the error banner replaces the
589
+ // <Tldraw> component and the editor is never recreated.
590
+ setExecError(null)
591
+ const code = params.arguments?.code
592
+ if (typeof code !== 'string' || !code.trim()) return
593
+ const canvasId =
594
+ typeof params.arguments?.canvasId === 'string' ? params.arguments.canvasId : undefined
595
+
596
+ if (execPartialDebounceRef.current !== null) {
597
+ window.clearTimeout(execPartialDebounceRef.current)
598
+ }
599
+ execPartialDebounceRef.current = window.setTimeout(() => {
600
+ execPartialDebounceRef.current = null
601
+ runExec(code, 'ontoolinput (debounced)', canvasId)
602
+ }, 500)
603
+ }
604
+
605
+ app.ontoolinputpartial = (params) => {
606
+ setExecError(null)
607
+ const code = params.arguments?.code
608
+ if (typeof code !== 'string' || !code.trim()) return
609
+ const canvasId =
610
+ typeof params.arguments?.canvasId === 'string' ? params.arguments.canvasId : undefined
611
+
612
+ if (execPartialDebounceRef.current !== null) {
613
+ window.clearTimeout(execPartialDebounceRef.current)
614
+ }
615
+ execPartialDebounceRef.current = window.setTimeout(() => {
616
+ execPartialDebounceRef.current = null
617
+ runExec(code, 'ontoolinputpartial (debounced)', canvasId)
618
+ }, 1000)
619
+ }
620
+
621
+ app.onteardown = async () => {
622
+ return {}
623
+ }
624
+
625
+ app.ontoolresult = async (result) => {
626
+ logIfDevMode('Exec: ontoolresult called')
627
+ hasExecRunRef.current = false
628
+ markAiActivity()
629
+
630
+ const checkpoint = parseCheckpointFromToolResult(result)
631
+ if (!checkpoint) return
632
+ logIfDevMode(`Received tool result for checkpoint ${checkpoint.checkpointId}`)
633
+
634
+ const {
635
+ checkpointId,
636
+ sessionId,
637
+ shapes: resultShapes,
638
+ assets: resultAssets,
639
+ bindings: resultBindings,
640
+ } = checkpoint
641
+ checkpointIdRef.current = checkpointId
642
+
643
+ if (sessionId) {
644
+ setCurrentSessionId(sessionId)
645
+ }
646
+
647
+ const localSnapshot = loadLocalSnapshot(checkpointId)
648
+ const finalShapes = localSnapshot ? localSnapshot.shapes : resultShapes
649
+ const finalAssets: TLAsset[] = localSnapshot ? localSnapshot.assets : resultAssets
650
+ const finalBindings: TLBindingCreate[] = localSnapshot
651
+ ? localSnapshot.bindings
652
+ : resultBindings
653
+
654
+ const previousCommittedIds = new Set(committedSnapshotRef.current.shapes.map((s) => s.id))
655
+
656
+ const snapshot: CanvasSnapshot = {
657
+ shapes: finalShapes,
658
+ assets: finalAssets,
659
+ bindings: finalBindings,
660
+ }
661
+ committedSnapshotRef.current = snapshot
662
+
663
+ const editor = editorRef.current
664
+ if (!editor) {
665
+ pendingSnapshotRef.current = snapshot
666
+ return
667
+ }
668
+
669
+ applySnapshot(editor, snapshot)
670
+
671
+ if (!localSnapshot) {
672
+ const newIds = new Set<TLShapeId>()
673
+ for (const shape of finalShapes) {
674
+ if (!previousCommittedIds.has(shape.id)) newIds.add(shape.id)
675
+ }
676
+ zoomToFitRequestShapes(editor, newIds)
677
+ }
678
+
679
+ saveLocalSnapshot(checkpointId, finalShapes, finalAssets, finalBindings)
680
+ void saveCheckpointToServer(app, checkpointId, editor)
681
+ pushCanvasContext(app, editor)
682
+ }
683
+
684
+ app.ontoolcancelled = (_params) => {
685
+ hasExecRunRef.current = false
686
+ if (execPartialDebounceRef.current !== null) {
687
+ window.clearTimeout(execPartialDebounceRef.current)
688
+ execPartialDebounceRef.current = null
689
+ }
690
+ markAiActivity()
691
+ logIfDevMode('Tool invocation cancelled')
692
+ }
693
+
694
+ return () => {
695
+ teardownEditor()
696
+ }
697
+ }, [
698
+ app,
699
+ applyHostThemeToEditor,
700
+ enableDevMode,
701
+ logIfDevMode,
702
+ markAiActivity,
703
+ teardownEditor,
704
+ waitForEditor,
705
+ ])
706
+
707
+ useEffect(() => {
708
+ if (!isMobilePlatform || displayMode !== 'fullscreen') return
709
+
710
+ void app
711
+ .requestDisplayMode({ mode: 'inline' })
712
+ .then((result) => {
713
+ const actualMode = result.mode === 'fullscreen' ? 'fullscreen' : 'inline'
714
+ setDisplayMode(actualMode)
715
+ })
716
+ .catch(() => {})
717
+ }, [app, displayMode, isMobilePlatform])
718
+
719
+ useEffect(() => {
720
+ if (displayMode === 'fullscreen' && containerHeight) {
721
+ const h = `${containerHeight}px`
722
+ document.documentElement.style.height = h
723
+ document.body.style.height = h
724
+ } else {
725
+ document.documentElement.style.height = ''
726
+ document.body.style.height = ''
727
+ }
728
+ }, [displayMode, containerHeight])
729
+
730
+ const handleMount = useCallback(
731
+ (editor: Editor) => {
732
+ editorRef.current = editor
733
+ editor.user.updateUserPreferences({ colorScheme: canvasTheme })
734
+
735
+ if (editorReadyResolveRef.current) {
736
+ editorReadyResolveRef.current(editor)
737
+ editorReadyResolveRef.current = null
738
+ editorReadyPromiseRef.current = null
739
+ }
740
+
741
+ removeStoreListenerRef.current?.()
742
+ removeStoreListenerRef.current = editor.store.listen(
743
+ () => {
744
+ markUserEdit()
745
+ scheduleSave()
746
+ },
747
+ { source: 'user', scope: 'document' }
748
+ )
749
+
750
+ editor.sideEffects.registerAfterChangeHandler('instance', (prev, next) => {
751
+ const pb = prev.screenBounds
752
+ const nb = next.screenBounds
753
+ const dw = nb.w - pb.w
754
+ const dh = nb.h - pb.h
755
+ if (dw === 0 && dh === 0) return
756
+
757
+ const cam = editor.getCamera()
758
+ if (!Number.isFinite(cam.z) || cam.z <= 0) return
759
+ const nextX = cam.x + dw / cam.z / 2
760
+ const nextY = cam.y + dh / cam.z / 2
761
+ if (!Number.isFinite(nextX) || !Number.isFinite(nextY)) return
762
+
763
+ editor.setCamera({
764
+ x: nextX,
765
+ y: nextY,
766
+ z: cam.z,
767
+ })
768
+ })
769
+
770
+ const pendingSnapshot = pendingSnapshotRef.current
771
+ if (pendingSnapshot) {
772
+ pendingSnapshotRef.current = null
773
+ applySnapshot(editor, pendingSnapshot)
774
+ }
775
+
776
+ pushCanvasContext(app, editor)
777
+ },
778
+ [app, canvasTheme, markUserEdit, scheduleSave]
779
+ )
780
+
781
+ if (execError) {
782
+ return (
783
+ <div className={`mcp-app__error-banner${isDarkTheme ? ' mcp-app__error-banner--dark' : ''}`}>
784
+ <img
785
+ src={tldrawLogoUrl}
786
+ alt="tldraw logo"
787
+ className={`mcp-app__error-logo${isDarkTheme ? ' mcp-app__error-logo--dark' : ''}`}
788
+ />
789
+ <span className="mcp-app__error-label">Error editing canvas:</span>
790
+ <span
791
+ title={execError}
792
+ className={`mcp-app__error-message${isDarkTheme ? ' mcp-app__error-message--dark' : ''}`}
793
+ >
794
+ {execError}
795
+ </span>
796
+ </div>
797
+ )
798
+ }
799
+
800
+ const isFullscreen = displayMode === 'fullscreen'
801
+
802
+ return (
803
+ <McpAppContext.Provider value={mcpAppCtx}>
804
+ <div
805
+ className={`mcp-app__canvas-layout${isFullscreen ? ' mcp-app__canvas-layout--fullscreen' : ''}`}
806
+ >
807
+ <div
808
+ className={`mcp-app__canvas-surface${isFullscreen ? ' mcp-app__canvas-surface--fullscreen' : ''}${devLogPanelHeight > 0 && !isFullscreen ? ' mcp-app__canvas-surface--with-dev-log' : ''}`}
809
+ >
810
+ <Tldraw
811
+ licenseKey={LICENSE_KEY}
812
+ onMount={handleMount}
813
+ onUiEvent={handleUiEvent}
814
+ components={tldrawComponents}
815
+ overrides={uiOverrides}
816
+ >
817
+ <ImageDropGuard />
818
+ </Tldraw>
819
+ </div>
820
+ {isDev && isDevLogVisible && (
821
+ <DevLogPanel entries={devLogEntries} isFullscreen={isFullscreen} />
822
+ )}
823
+ </div>
824
+ </McpAppContext.Provider>
825
+ )
826
+ }
827
+
828
+ function McpApp() {
829
+ const { app, isConnected, error } = useApp({
830
+ appInfo: {
831
+ name: MCP_SERVER_NAME,
832
+ version: MCP_SERVER_VERSION,
833
+ title: MCP_SERVER_TITLE,
834
+ description: MCP_SERVER_DESCRIPTION,
835
+ websiteUrl: MCP_SERVER_WEBSITE_URL,
836
+ },
837
+ capabilities: {
838
+ availableDisplayModes: ['fullscreen', 'inline'],
839
+ },
840
+ })
841
+
842
+ const status = isConnected ? 'ready' : 'connecting'
843
+
844
+ return (
845
+ <div>
846
+ {error ? (
847
+ <div className="mcp-app__status mcp-app__status--error">Error: {error.message}</div>
848
+ ) : !isConnected || !app ? (
849
+ <div className="mcp-app__status mcp-app__status--connecting">Status: {status}</div>
850
+ ) : (
851
+ <TldrawCanvas app={app} />
852
+ )}
853
+ </div>
854
+ )
855
+ }
856
+
857
+ createRoot(document.getElementById('root')!).render(<McpApp />)