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,164 @@
1
+ {
2
+ "artifact": "static/app-bridge-bundle.js",
3
+ "builder": "scripts/build-bridge.mjs",
4
+ "entry": "bridge/app-bridge-entry.js",
5
+ "format": "iife",
6
+ "platform": "browser",
7
+ "target": "es2020",
8
+ "dependencies": {
9
+ "@modelcontextprotocol/ext-apps": "1.7.4",
10
+ "@modelcontextprotocol/sdk": "1.29.0",
11
+ "zod": "4.4.3",
12
+ "esbuild": "0.28.1"
13
+ },
14
+ "hashes": {
15
+ "entrySha256": "e79b24d4a80e5d807224d4d424952945569b61b0038fc54eb7d05c7b8342c185",
16
+ "packageLockSha256": "3b9fbb2b2ffcf8e2bdda32c7eb408d13e5dab795d38a842cec9abb7f1c6829bc",
17
+ "bundleSha256": "275806b6c861f62a3d9fb9301aa6dad93457315c86ddb937d3a1e4a3352785bd"
18
+ },
19
+ "inputs": [
20
+ "bridge/app-bridge-entry.js",
21
+ "node_modules/@modelcontextprotocol/ext-apps/dist/src/app-bridge.js",
22
+ "node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/interfaces.js",
23
+ "node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js",
24
+ "node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js",
25
+ "node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js",
26
+ "node_modules/@modelcontextprotocol/sdk/dist/esm/types.js",
27
+ "node_modules/zod-to-json-schema/dist/esm/Options.js",
28
+ "node_modules/zod-to-json-schema/dist/esm/Refs.js",
29
+ "node_modules/zod-to-json-schema/dist/esm/errorMessages.js",
30
+ "node_modules/zod-to-json-schema/dist/esm/getRelativePath.js",
31
+ "node_modules/zod-to-json-schema/dist/esm/index.js",
32
+ "node_modules/zod-to-json-schema/dist/esm/parseDef.js",
33
+ "node_modules/zod-to-json-schema/dist/esm/parseTypes.js",
34
+ "node_modules/zod-to-json-schema/dist/esm/parsers/any.js",
35
+ "node_modules/zod-to-json-schema/dist/esm/parsers/array.js",
36
+ "node_modules/zod-to-json-schema/dist/esm/parsers/bigint.js",
37
+ "node_modules/zod-to-json-schema/dist/esm/parsers/boolean.js",
38
+ "node_modules/zod-to-json-schema/dist/esm/parsers/branded.js",
39
+ "node_modules/zod-to-json-schema/dist/esm/parsers/catch.js",
40
+ "node_modules/zod-to-json-schema/dist/esm/parsers/date.js",
41
+ "node_modules/zod-to-json-schema/dist/esm/parsers/default.js",
42
+ "node_modules/zod-to-json-schema/dist/esm/parsers/effects.js",
43
+ "node_modules/zod-to-json-schema/dist/esm/parsers/enum.js",
44
+ "node_modules/zod-to-json-schema/dist/esm/parsers/intersection.js",
45
+ "node_modules/zod-to-json-schema/dist/esm/parsers/literal.js",
46
+ "node_modules/zod-to-json-schema/dist/esm/parsers/map.js",
47
+ "node_modules/zod-to-json-schema/dist/esm/parsers/nativeEnum.js",
48
+ "node_modules/zod-to-json-schema/dist/esm/parsers/never.js",
49
+ "node_modules/zod-to-json-schema/dist/esm/parsers/null.js",
50
+ "node_modules/zod-to-json-schema/dist/esm/parsers/nullable.js",
51
+ "node_modules/zod-to-json-schema/dist/esm/parsers/number.js",
52
+ "node_modules/zod-to-json-schema/dist/esm/parsers/object.js",
53
+ "node_modules/zod-to-json-schema/dist/esm/parsers/optional.js",
54
+ "node_modules/zod-to-json-schema/dist/esm/parsers/pipeline.js",
55
+ "node_modules/zod-to-json-schema/dist/esm/parsers/promise.js",
56
+ "node_modules/zod-to-json-schema/dist/esm/parsers/readonly.js",
57
+ "node_modules/zod-to-json-schema/dist/esm/parsers/record.js",
58
+ "node_modules/zod-to-json-schema/dist/esm/parsers/set.js",
59
+ "node_modules/zod-to-json-schema/dist/esm/parsers/string.js",
60
+ "node_modules/zod-to-json-schema/dist/esm/parsers/tuple.js",
61
+ "node_modules/zod-to-json-schema/dist/esm/parsers/undefined.js",
62
+ "node_modules/zod-to-json-schema/dist/esm/parsers/union.js",
63
+ "node_modules/zod-to-json-schema/dist/esm/parsers/unknown.js",
64
+ "node_modules/zod-to-json-schema/dist/esm/selectParser.js",
65
+ "node_modules/zod-to-json-schema/dist/esm/zodToJsonSchema.js",
66
+ "node_modules/zod/v3/ZodError.js",
67
+ "node_modules/zod/v3/errors.js",
68
+ "node_modules/zod/v3/external.js",
69
+ "node_modules/zod/v3/helpers/errorUtil.js",
70
+ "node_modules/zod/v3/helpers/parseUtil.js",
71
+ "node_modules/zod/v3/helpers/typeAliases.js",
72
+ "node_modules/zod/v3/helpers/util.js",
73
+ "node_modules/zod/v3/index.js",
74
+ "node_modules/zod/v3/locales/en.js",
75
+ "node_modules/zod/v3/types.js",
76
+ "node_modules/zod/v4-mini/index.js",
77
+ "node_modules/zod/v4/classic/checks.js",
78
+ "node_modules/zod/v4/classic/coerce.js",
79
+ "node_modules/zod/v4/classic/compat.js",
80
+ "node_modules/zod/v4/classic/errors.js",
81
+ "node_modules/zod/v4/classic/external.js",
82
+ "node_modules/zod/v4/classic/from-json-schema.js",
83
+ "node_modules/zod/v4/classic/index.js",
84
+ "node_modules/zod/v4/classic/iso.js",
85
+ "node_modules/zod/v4/classic/parse.js",
86
+ "node_modules/zod/v4/classic/schemas.js",
87
+ "node_modules/zod/v4/core/api.js",
88
+ "node_modules/zod/v4/core/checks.js",
89
+ "node_modules/zod/v4/core/core.js",
90
+ "node_modules/zod/v4/core/doc.js",
91
+ "node_modules/zod/v4/core/errors.js",
92
+ "node_modules/zod/v4/core/index.js",
93
+ "node_modules/zod/v4/core/json-schema-generator.js",
94
+ "node_modules/zod/v4/core/json-schema-processors.js",
95
+ "node_modules/zod/v4/core/json-schema.js",
96
+ "node_modules/zod/v4/core/parse.js",
97
+ "node_modules/zod/v4/core/regexes.js",
98
+ "node_modules/zod/v4/core/registries.js",
99
+ "node_modules/zod/v4/core/schemas.js",
100
+ "node_modules/zod/v4/core/to-json-schema.js",
101
+ "node_modules/zod/v4/core/util.js",
102
+ "node_modules/zod/v4/core/versions.js",
103
+ "node_modules/zod/v4/index.js",
104
+ "node_modules/zod/v4/locales/ar.js",
105
+ "node_modules/zod/v4/locales/az.js",
106
+ "node_modules/zod/v4/locales/be.js",
107
+ "node_modules/zod/v4/locales/bg.js",
108
+ "node_modules/zod/v4/locales/ca.js",
109
+ "node_modules/zod/v4/locales/cs.js",
110
+ "node_modules/zod/v4/locales/da.js",
111
+ "node_modules/zod/v4/locales/de.js",
112
+ "node_modules/zod/v4/locales/el.js",
113
+ "node_modules/zod/v4/locales/en.js",
114
+ "node_modules/zod/v4/locales/eo.js",
115
+ "node_modules/zod/v4/locales/es.js",
116
+ "node_modules/zod/v4/locales/fa.js",
117
+ "node_modules/zod/v4/locales/fi.js",
118
+ "node_modules/zod/v4/locales/fr-CA.js",
119
+ "node_modules/zod/v4/locales/fr.js",
120
+ "node_modules/zod/v4/locales/he.js",
121
+ "node_modules/zod/v4/locales/hr.js",
122
+ "node_modules/zod/v4/locales/hu.js",
123
+ "node_modules/zod/v4/locales/hy.js",
124
+ "node_modules/zod/v4/locales/id.js",
125
+ "node_modules/zod/v4/locales/index.js",
126
+ "node_modules/zod/v4/locales/is.js",
127
+ "node_modules/zod/v4/locales/it.js",
128
+ "node_modules/zod/v4/locales/ja.js",
129
+ "node_modules/zod/v4/locales/ka.js",
130
+ "node_modules/zod/v4/locales/kh.js",
131
+ "node_modules/zod/v4/locales/km.js",
132
+ "node_modules/zod/v4/locales/ko.js",
133
+ "node_modules/zod/v4/locales/lt.js",
134
+ "node_modules/zod/v4/locales/mk.js",
135
+ "node_modules/zod/v4/locales/ms.js",
136
+ "node_modules/zod/v4/locales/nl.js",
137
+ "node_modules/zod/v4/locales/no.js",
138
+ "node_modules/zod/v4/locales/ota.js",
139
+ "node_modules/zod/v4/locales/pl.js",
140
+ "node_modules/zod/v4/locales/ps.js",
141
+ "node_modules/zod/v4/locales/pt.js",
142
+ "node_modules/zod/v4/locales/ro.js",
143
+ "node_modules/zod/v4/locales/ru.js",
144
+ "node_modules/zod/v4/locales/sl.js",
145
+ "node_modules/zod/v4/locales/sv.js",
146
+ "node_modules/zod/v4/locales/ta.js",
147
+ "node_modules/zod/v4/locales/th.js",
148
+ "node_modules/zod/v4/locales/tr.js",
149
+ "node_modules/zod/v4/locales/ua.js",
150
+ "node_modules/zod/v4/locales/uk.js",
151
+ "node_modules/zod/v4/locales/ur.js",
152
+ "node_modules/zod/v4/locales/uz.js",
153
+ "node_modules/zod/v4/locales/vi.js",
154
+ "node_modules/zod/v4/locales/yo.js",
155
+ "node_modules/zod/v4/locales/zh-CN.js",
156
+ "node_modules/zod/v4/locales/zh-TW.js",
157
+ "node_modules/zod/v4/mini/checks.js",
158
+ "node_modules/zod/v4/mini/coerce.js",
159
+ "node_modules/zod/v4/mini/external.js",
160
+ "node_modules/zod/v4/mini/iso.js",
161
+ "node_modules/zod/v4/mini/parse.js",
162
+ "node_modules/zod/v4/mini/schemas.js"
163
+ ]
164
+ }
@@ -0,0 +1,390 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Pi tldraw MCP host</title>
7
+ <style>
8
+ html, body { margin: 0; height: 100%; font-family: Inter, system-ui, sans-serif; }
9
+ #bar { height: 36px; display: flex; align-items: center; gap: 12px; padding: 0 12px; border-bottom: 1px solid #ddd; background: #fafafa; font-size: 13px; }
10
+ #status { color: #555; }
11
+ #folder { color: #888; font-variant-numeric: tabular-nums; max-width: 40vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
12
+ #canvas { width: 100%; height: calc(100vh - 36px); border: 0; display: block; }
13
+ button { font: inherit; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <div id="bar">
18
+ <strong>Pi tldraw MCP host</strong>
19
+ <span id="status">Starting…</span>
20
+ <span id="folder" title=""></span>
21
+ </div>
22
+ <iframe id="canvas" allow="clipboard-write; fullscreen"></iframe>
23
+ <script src="/static/app-bridge-bundle.js"></script>
24
+ <script>
25
+ const { AppBridge, PostMessageTransport } = window.McpAppsBridge
26
+
27
+ const statusEl = document.getElementById('status')
28
+ const folderEl = document.getElementById('folder')
29
+ const iframe = document.getElementById('canvas')
30
+ let bridge = null
31
+ let ready = false
32
+ let lastCanvasId = null
33
+ // Persist canvasId across reloads so we can restore after error recovery.
34
+ try { lastCanvasId = sessionStorage.getItem('pi-tldraw-canvasId') } catch {}
35
+ let needsRestore = lastCanvasId !== null
36
+ // Start inline so the later fullscreen update is a real context change.
37
+ // The tldraw MCP app initializes its React state to inline and only switches
38
+ // when it receives an onhostcontextchanged notification.
39
+ let displayMode = 'inline'
40
+ // Theme is owned by the host and persisted in localStorage so the user's
41
+ // choice (made via the tldraw settings menu) survives reloads. The tldraw
42
+ // app's onhostcontextchanged handler overrides its own color preference with
43
+ // hostContext.theme, so we send the persisted value ONCE at init and then stay
44
+ // hands-off — we never push a theme change mid-session, which would clobber
45
+ // the user's live settings-menu toggle. We only WATCH the iframe so we can
46
+ // remember the choice for next time.
47
+ let theme = (() => { try { return localStorage.getItem('pi-tldraw-theme-v2') === 'dark' ? 'dark' : 'light' } catch { return 'light' } })()
48
+
49
+ function persistTheme(next) {
50
+ if (next !== 'dark' && next !== 'light' || next === theme) return
51
+ theme = next
52
+ try { localStorage.setItem('pi-tldraw-theme-v2', theme) } catch {}
53
+ }
54
+
55
+ function getHostContext() {
56
+ return {
57
+ theme,
58
+ displayMode,
59
+ availableDisplayModes: ['inline', 'fullscreen'],
60
+ platform: 'desktop',
61
+ containerDimensions: { width: window.innerWidth, height: Math.max(400, window.innerHeight - 36) },
62
+ }
63
+ }
64
+
65
+ function setStatus(text) { statusEl.textContent = text; log('status', text) }
66
+ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) }
67
+ function log(level, message) {
68
+ console[level === 'error' ? 'error' : 'log']('[pi-tldraw-host]', message)
69
+ fetch('/api/log', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ level, message: String(message) }) }).catch(() => {})
70
+ }
71
+ window.addEventListener('error', (event) => log('error', event.message + (event.filename ? ' @ ' + event.filename + ':' + event.lineno : '')))
72
+ window.addEventListener('unhandledrejection', (event) => log('error', event.reason?.stack || event.reason?.message || event.reason))
73
+
74
+ class McpHttpClient {
75
+ constructor(endpoint) { this.endpoint = endpoint; this.sessionId = null; this.nextId = 1 }
76
+ async initialize() {
77
+ if (this.sessionId) return
78
+ const { response, payload } = await this.post({
79
+ jsonrpc: '2.0', id: this.nextId++, method: 'initialize',
80
+ params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'pi-tldraw-browser-host', version: '0.0.1' } }
81
+ })
82
+ this.sessionId = response.headers.get('mcp-session-id')
83
+ if (!this.sessionId) throw new Error('Missing mcp-session-id')
84
+ if (payload.error) throw new Error(payload.error.message)
85
+ await this.post({ jsonrpc: '2.0', method: 'notifications/initialized' })
86
+ }
87
+ async request(method, params) {
88
+ await this.initialize()
89
+ const { payload } = await this.post({ jsonrpc: '2.0', id: this.nextId++, method, params })
90
+ if (payload.error) throw new Error(payload.error.message)
91
+ return payload.result
92
+ }
93
+ async post(body) {
94
+ const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
95
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId
96
+ const response = await fetch(this.endpoint, { method: 'POST', headers, body: JSON.stringify(body) })
97
+ const text = await response.text()
98
+ if (!response.ok) throw new Error('MCP HTTP ' + response.status + ': ' + text)
99
+ return { response, payload: parseMcpResponse(text) }
100
+ }
101
+ }
102
+
103
+ function parseMcpResponse(text) {
104
+ const trimmed = text.trim()
105
+ if (!trimmed) return undefined
106
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) return JSON.parse(trimmed)
107
+ for (const line of trimmed.split(/\r?\n/)) {
108
+ if (line.startsWith('data:')) return JSON.parse(line.slice(5).trim())
109
+ }
110
+ throw new Error('Could not parse MCP response: ' + trimmed.slice(0, 200))
111
+ }
112
+
113
+ const fullscreenFixCss = [
114
+ 'html, body, #root { width: 100%; height: 100%; margin: 0; }',
115
+ '.mcp-app__canvas-layout, .mcp-app__canvas-layout--fullscreen { height: 100vh !important; min-height: 100vh !important; max-height: none !important; }',
116
+ '.mcp-app__canvas-layout--fullscreen { position: fixed !important; inset: 0 !important; width: 100vw !important; }',
117
+ '.mcp-app__canvas-surface, .mcp-app__canvas-surface--fullscreen { height: auto !important; min-height: 0 !important; flex: 1 1 auto !important; }',
118
+ ].join('\n')
119
+
120
+ function patchCanvasHtml(html) {
121
+ const style = '<style id="pi-tldraw-host-fullscreen-fix">' + fullscreenFixCss + '</style>'
122
+ return html.includes('</head>') ? html.replace('</head>', style + '</head>') : style + html
123
+ }
124
+
125
+ function installIframeFullscreenFix() {
126
+ const inject = () => {
127
+ const doc = iframe.contentDocument
128
+ if (!doc?.head) return false
129
+ let style = doc.getElementById('pi-tldraw-host-fullscreen-fix-live')
130
+ if (!style) {
131
+ style = doc.createElement('style')
132
+ style.id = 'pi-tldraw-host-fullscreen-fix-live'
133
+ doc.head.appendChild(style)
134
+ }
135
+ style.textContent = fullscreenFixCss
136
+ return true
137
+ }
138
+ let attempts = 0
139
+ const timer = setInterval(() => {
140
+ attempts++
141
+ inject()
142
+ if (attempts > 80) clearInterval(timer)
143
+ }, 100)
144
+ iframe.addEventListener('load', inject)
145
+ inject()
146
+ }
147
+
148
+ async function waitReady() {
149
+ for (let i = 0; i < 200; i++) {
150
+ if (ready) return
151
+ await delay(100)
152
+ }
153
+ throw new Error('Timed out waiting for tldraw iframe to initialize')
154
+ }
155
+
156
+ // Detect theme changes made inside the tldraw app (its own dark-mode menu)
157
+ // and persist them so they survive reloads. The app doesn't call back to the
158
+ // host on theme change, so we infer it from the iframe's effective background.
159
+ function detectIframeTheme() {
160
+ try {
161
+ const doc = iframe.contentDocument
162
+ if (!doc) return null
163
+ const el = doc.documentElement
164
+ const bg = getComputedStyle(el).backgroundColor || getComputedStyle(doc.body).backgroundColor
165
+ const m = bg.match(/\d+(?:\.\d+)?/g)
166
+ if (!m) return null
167
+ const [r, g, b] = m.map(Number)
168
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b)
169
+ return luminance < 128 ? 'dark' : 'light'
170
+ } catch { return null }
171
+ }
172
+
173
+ function startThemeSync() {
174
+ // Watch only: remember the user's settings-menu choice for the next open.
175
+ // Never push a hostContext change here — that would override the user's
176
+ // live toggle (the app's onhostcontextchanged forces colorScheme = our theme).
177
+ setInterval(() => {
178
+ if (!ready) return
179
+ const detected = detectIframeTheme()
180
+ if (detected) persistTheme(detected)
181
+ }, 1500)
182
+ }
183
+
184
+ async function init() {
185
+ const config = await (await fetch('/api/config')).json()
186
+ if (config.canvasDir) {
187
+ folderEl.textContent = '📁 ' + config.canvasDir
188
+ folderEl.title = config.canvasDir + (config.cwd ? '\ncwd: ' + config.cwd : '')
189
+ }
190
+ setStatus('Connecting to MCP…')
191
+ const client = new McpHttpClient(config.endpoint)
192
+ await client.initialize()
193
+
194
+ setStatus('Fetching canvas resource…')
195
+ const resource = await client.request('resources/read', { uri: config.resourceUri })
196
+ let html = resource?.contents?.[0]?.text
197
+ if (!html) throw new Error('Canvas resource did not contain HTML')
198
+ html = patchCanvasHtml(html)
199
+
200
+ bridge = new AppBridge(
201
+ null,
202
+ { name: 'Pi tldraw host', version: '0.0.1' },
203
+ {
204
+ openLinks: {}, downloadFile: {}, logging: {},
205
+ serverTools: { listChanged: true },
206
+ serverResources: { listChanged: true },
207
+ updateModelContext: { text: {}, structuredContent: {} },
208
+ message: { text: {}, structuredContent: {} },
209
+ },
210
+ { hostContext: getHostContext() }
211
+ )
212
+ bridge.oninitialized = () => {
213
+ ready = true
214
+ // The tldraw MCP app currently starts in inline mode even when the initial
215
+ // host context says fullscreen. Flip to fullscreen after init and send the
216
+ // notification a few times because the React handler may attach just after
217
+ // the protocol initialization completes.
218
+ displayMode = 'fullscreen'
219
+ const sendFullscreenContext = () => {
220
+ bridge.sendHostContextChange(getHostContext()).catch((err) => log('error', err?.message ?? err))
221
+ }
222
+ sendFullscreenContext()
223
+ setTimeout(sendFullscreenContext, 100)
224
+ setTimeout(sendFullscreenContext, 500)
225
+ setTimeout(sendFullscreenContext, 1000)
226
+ setStatus('Canvas ready')
227
+ startThemeSync()
228
+ }
229
+ bridge.onsizechange = () => {}
230
+ bridge.oncalltool = (params) => client.request('tools/call', params)
231
+ bridge.onlistresources = (params) => client.request('resources/list', params ?? {})
232
+ bridge.onreadresource = (params) => client.request('resources/read', params)
233
+ bridge.onlistresourcetemplates = async () => ({ resourceTemplates: [] })
234
+ bridge.onmessage = async ({ content }) => { console.log('ui/message', content); return {} }
235
+ bridge.onupdatemodelcontext = async (params) => { scheduleProjectAutoSave(params); return {} }
236
+ bridge.onopenlink = async ({ url }) => { window.open(url, '_blank', 'noopener,noreferrer'); return {} }
237
+ bridge.ondownloadfile = async () => ({})
238
+ bridge.onrequestdisplaymode = async ({ mode }) => {
239
+ displayMode = mode === 'fullscreen' ? 'fullscreen' : 'inline'
240
+ bridge.setHostContext(getHostContext())
241
+ return { mode: displayMode }
242
+ }
243
+ bridge.onloggingmessage = ({ level, data }) => console.log('[iframe]', level, data)
244
+
245
+ const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow)
246
+ await bridge.connect(transport)
247
+
248
+ // Keep the iframe's WindowProxy object for PostMessageTransport source validation, but
249
+ // use srcdoc instead of document.write so module scripts run consistently.
250
+ iframe.srcdoc = html
251
+ installIframeFullscreenFix()
252
+
253
+ window.addEventListener('resize', () => {
254
+ bridge.setHostContext(getHostContext())
255
+ })
256
+
257
+ pollLoop(client)
258
+ }
259
+
260
+ let autoSaveTimer = null
261
+ let pendingAutoSave = null
262
+
263
+ function scheduleProjectAutoSave(params) {
264
+ const state = params?.structuredContent
265
+ if (!lastCanvasId || !state || !Array.isArray(state.shapes)) return
266
+ pendingAutoSave = {
267
+ canvasId: lastCanvasId,
268
+ state: {
269
+ shapes: state.shapes,
270
+ assets: Array.isArray(state.assets) ? state.assets : [],
271
+ bindings: Array.isArray(state.bindings) ? state.bindings : [],
272
+ },
273
+ source: 'ui/update-model-context',
274
+ }
275
+ if (autoSaveTimer) clearTimeout(autoSaveTimer)
276
+ autoSaveTimer = setTimeout(flushProjectAutoSave, 750)
277
+ }
278
+
279
+ async function flushProjectAutoSave() {
280
+ const payload = pendingAutoSave
281
+ pendingAutoSave = null
282
+ autoSaveTimer = null
283
+ if (!payload) return
284
+ try {
285
+ const response = await fetch('/api/autosave', {
286
+ method: 'POST',
287
+ headers: { 'content-type': 'application/json' },
288
+ body: JSON.stringify(payload),
289
+ })
290
+ const result = await response.json().catch(() => ({}))
291
+ if (!response.ok || result.ok === false) throw new Error(result.error || 'autosave failed')
292
+ log('status', 'Autosaved ' + payload.canvasId + ' · ' + payload.state.shapes.length + ' shape(s)')
293
+ } catch (error) {
294
+ log('error', 'Autosave failed: ' + String(error?.message ?? error))
295
+ }
296
+ }
297
+
298
+ async function runExec(client, task) {
299
+ await waitReady()
300
+ const args = { code: task.code }
301
+ if (task.canvasId || lastCanvasId) args.canvasId = task.canvasId || lastCanvasId
302
+
303
+ setStatus('Running exec ' + task.id.slice(0, 8) + '…')
304
+ const toolPromise = client.request('tools/call', { name: 'exec', arguments: args })
305
+ // Give the server a moment to create its pending callback before the iframe calls _exec_callback.
306
+ await delay(200)
307
+ await bridge.sendToolInput({ arguments: args })
308
+ const result = await toolPromise
309
+ await bridge.sendToolResult(result).catch(() => {})
310
+
311
+ const text = result?.content?.find?.((c) => c.type === 'text')?.text
312
+ const match = typeof text === 'string' ? text.match(/Canvas ID: ([^\s]+)/) : null
313
+ if (match) {
314
+ lastCanvasId = match[1]
315
+ try { sessionStorage.setItem('pi-tldraw-canvasId', lastCanvasId) } catch {}
316
+ }
317
+ setStatus('Canvas ready' + (lastCanvasId ? ' · ' + lastCanvasId : ''))
318
+ return result
319
+ }
320
+
321
+ async function pollLoop(client) {
322
+ while (true) {
323
+ // After a reload (error recovery), restore the saved canvas before
324
+ // processing any exec tasks. The iframe is fresh and empty; the
325
+ // server state was cleared by the error. We pull the saved snapshot
326
+ // from the extension's project store and push it into the iframe.
327
+ if (needsRestore && lastCanvasId) {
328
+ needsRestore = false
329
+ try {
330
+ await waitReady()
331
+ const resp = await fetch('/api/restore?canvasId=' + encodeURIComponent(lastCanvasId))
332
+ const data = await resp.json()
333
+ if (data.ok && data.snapshot?.shapes?.length > 0) {
334
+ const restoreCode = 'const snapshotShapes = ' + JSON.stringify(data.snapshot.shapes) + '\n' +
335
+ 'const currentIds = editor.getCurrentPageShapes().map(s => s.shapeId ?? s.id).filter(Boolean)\n' +
336
+ 'if (currentIds.length) editor.deleteShapes(currentIds)\n' +
337
+ 'const nonArrows = snapshotShapes.filter(s => (s._type ?? s.type) !== \'arrow\')\n' +
338
+ 'const arrows = snapshotShapes.filter(s => (s._type ?? s.type) === \'arrow\')\n' +
339
+ 'for (const s of nonArrows) editor.createShape(s)\n' +
340
+ 'for (const s of arrows) editor.createShape(s)\n' +
341
+ 'return { restored: snapshotShapes.length }'
342
+ const restoreArgs = { code: restoreCode, canvasId: lastCanvasId }
343
+ const restorePromise = client.request('tools/call', { name: 'exec', arguments: restoreArgs })
344
+ await delay(200)
345
+ await bridge.sendToolInput({ arguments: restoreArgs })
346
+ await restorePromise
347
+ log('status', 'Restored ' + data.snapshot.shapes.length + ' shape(s) after reload')
348
+ setStatus('Canvas restored · ' + lastCanvasId)
349
+ } else {
350
+ log('status', 'No saved snapshot to restore after reload')
351
+ }
352
+ } catch (err) {
353
+ log('error', 'Restore after reload failed: ' + String(err?.message ?? err))
354
+ }
355
+ }
356
+ try {
357
+ const task = await (await fetch('/api/next')).json()
358
+ if (!task) { await delay(500); continue }
359
+ try {
360
+ const result = await runExec(client, task)
361
+ await fetch('/api/result', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: task.id, ok: true, result }) })
362
+ // If the exec returned an error, the iframe's editor is now in a
363
+ // broken state (error banner, editor disposed, never remounts).
364
+ // Reload the host page to get a fresh iframe. The canvas state is
365
+ // preserved — the next exec passes canvasId and the mcp-app
366
+ // restores shapes from the server via _get_canvas_state.
367
+ // This keeps the extension self-contained: no upstream patch needed.
368
+ if (result?.isError) {
369
+ log('status', 'Exec error, reloading host to recover...')
370
+ setStatus('Recovering from error...')
371
+ await delay(300)
372
+ window.location.reload()
373
+ return
374
+ }
375
+ } catch (error) {
376
+ await bridge?.sendToolCancelled?.({ reason: String(error?.message ?? error) }).catch(() => {})
377
+ await fetch('/api/result', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: task.id, ok: false, error: String(error?.message ?? error) }) })
378
+ setStatus('Error: ' + String(error?.message ?? error))
379
+ }
380
+ } catch (error) {
381
+ console.error(error)
382
+ await delay(1000)
383
+ }
384
+ }
385
+ }
386
+
387
+ init().catch((error) => { console.error(error); setStatus('Error: ' + error.message) })
388
+ </script>
389
+ </body>
390
+ </html>
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "types": ["node", "vitest/globals"]
11
+ },
12
+ "include": ["src/**/*.ts", "test/**/*.ts"]
13
+ }