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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/bridge/app-bridge-entry.js +6 -0
- package/mcp-app/LICENSE.md +9 -0
- package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
- package/mcp-app/README.md +129 -0
- package/mcp-app/dev-tunnel.sh +51 -0
- package/mcp-app/dist/editor-api.json +8493 -0
- package/mcp-app/dist/mcp-app.html +643 -0
- package/mcp-app/dist/method-map.json +915 -0
- package/mcp-app/package.json +42 -0
- package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
- package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
- package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
- package/mcp-app/scripts/extract-editor-api.ts +1374 -0
- package/mcp-app/server.json +21 -0
- package/mcp-app/src/logger.ts +45 -0
- package/mcp-app/src/register-tools.ts +368 -0
- package/mcp-app/src/shared/generated-data.ts +160 -0
- package/mcp-app/src/shared/pending-requests.ts +69 -0
- package/mcp-app/src/shared/types.ts +76 -0
- package/mcp-app/src/shared/utils.ts +132 -0
- package/mcp-app/src/tools/exec.ts +120 -0
- package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
- package/mcp-app/src/tools/search.ts +150 -0
- package/mcp-app/src/widget/app-context.tsx +29 -0
- package/mcp-app/src/widget/dev-log.tsx +70 -0
- package/mcp-app/src/widget/exec-helpers.ts +232 -0
- package/mcp-app/src/widget/export-tldr.ts +35 -0
- package/mcp-app/src/widget/focused/defaults.ts +141 -0
- package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
- package/mcp-app/src/widget/focused/format.ts +366 -0
- package/mcp-app/src/widget/focused/to-focused.ts +258 -0
- package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
- package/mcp-app/src/widget/image-guard.tsx +106 -0
- package/mcp-app/src/widget/index.html +33 -0
- package/mcp-app/src/widget/mcp-app.css +113 -0
- package/mcp-app/src/widget/mcp-app.tsx +857 -0
- package/mcp-app/src/widget/persistence.ts +337 -0
- package/mcp-app/src/widget/snapshot.ts +157 -0
- package/mcp-app/src/worker.ts +305 -0
- package/mcp-app/tsconfig.json +23 -0
- package/mcp-app/vite.config.ts +13 -0
- package/mcp-app/wrangler.toml +45 -0
- package/mcp-app-source.json +36 -0
- package/package.json +90 -0
- package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
- package/scripts/assemble-mcp-app.mjs +193 -0
- package/scripts/build-bridge.mjs +74 -0
- package/scripts/e2e-mcp.mjs +69 -0
- package/scripts/e2e-packaged-mcp-app.mjs +79 -0
- package/scripts/run-mcp-app-dev.mjs +44 -0
- package/scripts/verify-bundle.mjs +41 -0
- package/scripts/verify-mcp-app-source.mjs +51 -0
- package/scripts/verify-mcp-app.mjs +38 -0
- package/scripts/verify-package-files.mjs +50 -0
- package/src/canvas/export.ts +164 -0
- package/src/canvas/state.ts +117 -0
- package/src/canvas/workflow.ts +105 -0
- package/src/commands/tldraw-command.ts +48 -0
- package/src/diagram/guidance.ts +44 -0
- package/src/host/local-host.ts +289 -0
- package/src/index.ts +762 -0
- package/src/mcp/client.ts +126 -0
- package/src/mcp/response.ts +74 -0
- package/src/semantic/layer.ts +309 -0
- package/src/server/server-manager.ts +153 -0
- package/src/store/export-store.ts +33 -0
- package/src/store/project-store.ts +251 -0
- package/src/ui/tldraw-status.ts +88 -0
- package/static/app-bridge-bundle.js +18114 -0
- package/static/app-bridge-bundle.meta.json +164 -0
- package/static/host.html +390 -0
- 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
|
+
}
|
package/static/host.html
ADDED
|
@@ -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
|
+
}
|