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,193 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
5
+ import { dirname, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { spawnSync } from 'node:child_process'
8
+
9
+ const args = new Set(process.argv.slice(2))
10
+ const usePinnedSource = args.has('--pinned')
11
+ const skipBuild = args.has('--skip-build')
12
+
13
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
14
+ const manifestPath = resolve(root, 'mcp-app-source.json')
15
+ const manifest = existsSync(manifestPath) ? JSON.parse(await readText(manifestPath)) : null
16
+ const defaultSource = resolve(root, '../tldraw/apps/mcp-app')
17
+ const targetDir = resolve(root, 'mcp-app')
18
+ const sourceDir = usePinnedSource
19
+ ? await preparePinnedSource()
20
+ : resolve(process.env.TLDRAW_MCP_APP_SOURCE_DIR || defaultSource)
21
+
22
+ const requiredFiles = [
23
+ 'package.json',
24
+ 'wrangler.toml',
25
+ 'src/worker.ts',
26
+ 'src/register-tools.ts',
27
+ 'dist/mcp-app.html',
28
+ 'dist/editor-api.json',
29
+ 'dist/method-map.json',
30
+ ]
31
+
32
+ function sha256(value) {
33
+ return createHash('sha256').update(value).digest('hex')
34
+ }
35
+
36
+ async function readText(path) {
37
+ return readFile(path, 'utf8')
38
+ }
39
+
40
+ function run(command, commandArgs, opts = {}) {
41
+ const result = spawnSync(command, commandArgs, { encoding: 'utf8', stdio: 'pipe', ...opts })
42
+ if (result.status !== 0) {
43
+ throw new Error(
44
+ [
45
+ `Command failed: ${command} ${commandArgs.join(' ')}`,
46
+ result.stdout?.trim(),
47
+ result.stderr?.trim(),
48
+ ]
49
+ .filter(Boolean)
50
+ .join('\n')
51
+ )
52
+ }
53
+ return result.stdout.trim()
54
+ }
55
+
56
+ function git(cwd, gitArgs) {
57
+ const result = spawnSync('git', gitArgs, { cwd, encoding: 'utf8' })
58
+ return result.status === 0 ? result.stdout.trim() : ''
59
+ }
60
+
61
+ function shell(cwd, command) {
62
+ const result = spawnSync(command, { cwd, encoding: 'utf8', stdio: 'inherit', shell: true })
63
+ if (result.status !== 0) throw new Error(`Command failed in ${cwd}: ${command}`)
64
+ }
65
+
66
+ function findGitRoot(path) {
67
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: path, encoding: 'utf8' })
68
+ return result.status === 0 ? result.stdout.trim() : undefined
69
+ }
70
+
71
+ async function patchMetadata() {
72
+ if (!manifest?.patches?.length) return []
73
+ return Promise.all(
74
+ manifest.patches.map(async (patchPath) => {
75
+ const absolutePath = resolve(root, patchPath)
76
+ const body = await readText(absolutePath)
77
+ return { path: patchPath, sha256: sha256(body) }
78
+ })
79
+ )
80
+ }
81
+
82
+ async function preparePinnedSource() {
83
+ if (!manifest) throw new Error(`Missing ${manifestPath}; cannot assemble pinned mcp-app source`)
84
+ const buildRoot = resolve(process.env.TLDRAW_MCP_APP_BUILD_DIR || resolve(root, '.pi/build/tldraw-mcp-source'))
85
+ await rm(buildRoot, { recursive: true, force: true })
86
+ await mkdir(dirname(buildRoot), { recursive: true })
87
+
88
+ console.log(`cloning ${manifest.repo} -> ${buildRoot}`)
89
+ run('git', ['clone', manifest.repo, buildRoot], { cwd: root })
90
+ run('git', ['checkout', manifest.commit], { cwd: buildRoot })
91
+
92
+ for (const patchPath of manifest.patches ?? []) {
93
+ const absolutePatch = resolve(root, patchPath)
94
+ console.log(`applying ${patchPath}`)
95
+ run('git', ['apply', absolutePatch], { cwd: buildRoot })
96
+ }
97
+
98
+ if (!skipBuild) {
99
+ for (const step of manifest.build ?? []) {
100
+ const cwd = resolve(buildRoot, step.cwd ?? '.')
101
+ console.log(`building in ${cwd}: ${step.command}`)
102
+ shell(cwd, step.command)
103
+ }
104
+ } else {
105
+ console.warn('skipping pinned source build because --skip-build was passed')
106
+ }
107
+
108
+ return resolve(buildRoot, manifest.appPath)
109
+ }
110
+
111
+ async function copyIfExists(relativePath) {
112
+ const src = resolve(sourceDir, relativePath)
113
+ if (!existsSync(src)) return false
114
+ const dest = resolve(targetDir, relativePath)
115
+ await mkdir(dirname(dest), { recursive: true })
116
+ await cp(src, dest, { recursive: true })
117
+ return true
118
+ }
119
+
120
+ for (const file of requiredFiles) {
121
+ if (!existsSync(resolve(sourceDir, file))) {
122
+ throw new Error(
123
+ `Missing ${file} in ${sourceDir}. Build the tldraw MCP app first, e.g. cd ${sourceDir} && yarn build`
124
+ )
125
+ }
126
+ }
127
+
128
+ await rm(targetDir, { recursive: true, force: true })
129
+ await mkdir(targetDir, { recursive: true })
130
+
131
+ for (const path of manifest?.assembledFiles ?? [
132
+ 'LICENSE.md',
133
+ 'README.md',
134
+ 'package.json',
135
+ 'server.json',
136
+ 'tsconfig.json',
137
+ 'vite.config.ts',
138
+ 'wrangler.toml',
139
+ 'dev-tunnel.sh',
140
+ 'src',
141
+ 'scripts',
142
+ 'plugins',
143
+ 'dist',
144
+ ]) {
145
+ await copyIfExists(path.replace(/\/$/, ''))
146
+ }
147
+
148
+ const pkgPath = resolve(targetDir, 'package.json')
149
+ const pkg = JSON.parse(await readText(pkgPath))
150
+ pkg.private = true
151
+ pkg.scripts = {
152
+ ...pkg.scripts,
153
+ // Runtime packages ship prebuilt dist assets. Do not rebuild tldraw on user machines.
154
+ dev: 'yarn dev:http',
155
+ 'dev:http': 'node ../scripts/run-mcp-app-dev.mjs',
156
+ build: 'node -e "const fs=require(\'fs\'); for (const f of [\'dist/mcp-app.html\',\'dist/editor-api.json\',\'dist/method-map.json\']) if (!fs.existsSync(f)) throw new Error(`Missing ${f}`); console.log(\'prebuilt mcp-app dist is present\')"',
157
+ }
158
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, '\t')}\n`, 'utf8')
159
+
160
+ const gitRoot = findGitRoot(sourceDir)
161
+ const patches = await patchMetadata()
162
+ const sourceGitCommit = gitRoot ? git(gitRoot, ['rev-parse', 'HEAD']) : undefined
163
+ const sourceGitStatus = gitRoot ? git(gitRoot, ['status', '--short', '--', manifest?.appPath ?? 'apps/mcp-app']) : undefined
164
+ const provenance = {
165
+ artifact: 'mcp-app/',
166
+ builder: 'scripts/assemble-mcp-app.mjs',
167
+ source: {
168
+ mode: usePinnedSource ? 'pinned' : 'local',
169
+ repo: manifest?.repo,
170
+ commit: manifest?.commit,
171
+ appPath: manifest?.appPath,
172
+ patches,
173
+ build: manifest?.build,
174
+ sourceGitCommit,
175
+ ...(process.env.PI_TLDRAW_INCLUDE_LOCAL_PROVENANCE === 'true'
176
+ ? { sourceDir, sourceGitRoot: gitRoot, sourceGitStatus }
177
+ : {}),
178
+ },
179
+ dist: {
180
+ 'mcp-app.html': sha256(await readText(resolve(targetDir, 'dist/mcp-app.html'))),
181
+ 'editor-api.json': sha256(await readText(resolve(targetDir, 'dist/editor-api.json'))),
182
+ 'method-map.json': sha256(await readText(resolve(targetDir, 'dist/method-map.json'))),
183
+ },
184
+ }
185
+ await writeFile(resolve(targetDir, 'PI_TLDRAW_PROVENANCE.json'), `${JSON.stringify(provenance, null, 2)}\n`, 'utf8')
186
+ await writeFile(resolve(targetDir, '.npmignore'), '.wrangler/\nnode_modules/\n*.log\n', 'utf8')
187
+
188
+ console.log(`assembled ${targetDir}`)
189
+ console.log(`source: ${sourceDir}`)
190
+ if (sourceGitStatus) {
191
+ console.warn('source has mcp-app changes:')
192
+ console.warn(sourceGitStatus)
193
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto'
3
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import * as esbuild from 'esbuild'
7
+
8
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
9
+ const entry = resolve(root, 'bridge/app-bridge-entry.js')
10
+ const outfile = resolve(root, 'static/app-bridge-bundle.js')
11
+ const metafile = resolve(root, 'static/app-bridge-bundle.meta.json')
12
+ const lockfile = resolve(root, 'package-lock.json')
13
+
14
+ function sha256(value) {
15
+ return createHash('sha256').update(value).digest('hex')
16
+ }
17
+
18
+ async function readText(path) {
19
+ return readFile(path, 'utf8')
20
+ }
21
+
22
+ function packageVersionFromLock(lock, packageName) {
23
+ const entry = lock.packages?.[`node_modules/${packageName}`]
24
+ if (!entry?.version) throw new Error(`Could not find ${packageName} in package-lock.json`)
25
+ return entry.version
26
+ }
27
+
28
+ await mkdir(dirname(outfile), { recursive: true })
29
+
30
+ const result = await esbuild.build({
31
+ entryPoints: [entry],
32
+ bundle: true,
33
+ outfile,
34
+ format: 'iife',
35
+ platform: 'browser',
36
+ target: ['es2020'],
37
+ charset: 'utf8',
38
+ legalComments: 'inline',
39
+ minify: false,
40
+ metafile: true,
41
+ logLevel: 'info',
42
+ })
43
+
44
+ const [entryText, lockText, bundleText] = await Promise.all([
45
+ readText(entry),
46
+ readText(lockfile),
47
+ readText(outfile),
48
+ ])
49
+ const lock = JSON.parse(lockText)
50
+
51
+ const metadata = {
52
+ artifact: 'static/app-bridge-bundle.js',
53
+ builder: 'scripts/build-bridge.mjs',
54
+ entry: 'bridge/app-bridge-entry.js',
55
+ format: 'iife',
56
+ platform: 'browser',
57
+ target: 'es2020',
58
+ dependencies: {
59
+ '@modelcontextprotocol/ext-apps': packageVersionFromLock(lock, '@modelcontextprotocol/ext-apps'),
60
+ '@modelcontextprotocol/sdk': packageVersionFromLock(lock, '@modelcontextprotocol/sdk'),
61
+ zod: packageVersionFromLock(lock, 'zod'),
62
+ esbuild: packageVersionFromLock(lock, 'esbuild'),
63
+ },
64
+ hashes: {
65
+ entrySha256: sha256(entryText),
66
+ packageLockSha256: sha256(lockText),
67
+ bundleSha256: sha256(bundleText),
68
+ },
69
+ inputs: Object.keys(result.metafile.inputs).sort(),
70
+ }
71
+
72
+ await writeFile(metafile, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
73
+ console.log(`built ${outfile}`)
74
+ console.log(`wrote ${metafile}`)
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ const endpoint = process.env.TLDRAW_MCP_URL || 'http://127.0.0.1:8787/mcp'
3
+ const resourceUri = process.env.TLDRAW_MCP_RESOURCE_URI || 'ui://show-canvas/mcp-app.html'
4
+ let nextId = 1
5
+ let sessionId = null
6
+
7
+ function parseMcpResponse(text) {
8
+ const trimmed = text.trim()
9
+ if (!trimmed) return undefined
10
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) return JSON.parse(trimmed)
11
+ for (const line of trimmed.split(/\r?\n/)) {
12
+ if (line.startsWith('data:')) return JSON.parse(line.slice(5).trim())
13
+ }
14
+ throw new Error(`Could not parse MCP response: ${trimmed.slice(0, 200)}`)
15
+ }
16
+
17
+ async function post(body) {
18
+ const headers = {
19
+ 'content-type': 'application/json',
20
+ accept: 'application/json, text/event-stream',
21
+ }
22
+ if (sessionId) headers['mcp-session-id'] = sessionId
23
+ const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) })
24
+ const text = await response.text()
25
+ if (!response.ok) throw new Error(`MCP HTTP ${response.status}: ${text}`)
26
+ return { response, payload: parseMcpResponse(text) }
27
+ }
28
+
29
+ async function request(method, params = {}) {
30
+ const { payload } = await post({ jsonrpc: '2.0', id: nextId++, method, params })
31
+ if (payload?.error) throw new Error(payload.error.message)
32
+ return payload?.result
33
+ }
34
+
35
+ console.log(`checking ${endpoint}`)
36
+ const init = await post({
37
+ jsonrpc: '2.0',
38
+ id: nextId++,
39
+ method: 'initialize',
40
+ params: {
41
+ protocolVersion: '2025-06-18',
42
+ capabilities: {},
43
+ clientInfo: { name: 'pi-tldraw-e2e', version: '0.0.1' },
44
+ },
45
+ })
46
+ sessionId = init.response.headers.get('mcp-session-id')
47
+ if (!sessionId) throw new Error('missing mcp-session-id')
48
+ if (init.payload?.error) throw new Error(init.payload.error.message)
49
+ await post({ jsonrpc: '2.0', method: 'notifications/initialized' })
50
+
51
+ const [{ tools = [] }, { resources = [] }] = await Promise.all([
52
+ request('tools/list'),
53
+ request('resources/list'),
54
+ ])
55
+ const toolNames = new Set(tools.map((tool) => tool.name))
56
+ for (const required of ['search', 'exec']) {
57
+ if (!toolNames.has(required)) throw new Error(`missing required tool: ${required}`)
58
+ }
59
+ if (!resources.some((resource) => resource.uri === resourceUri)) {
60
+ throw new Error(`missing canvas resource: ${resourceUri}`)
61
+ }
62
+
63
+ const resource = await request('resources/read', { uri: resourceUri })
64
+ const html = resource?.contents?.[0]?.text
65
+ if (typeof html !== 'string' || !html.includes('<')) {
66
+ throw new Error('canvas resource did not return HTML text')
67
+ }
68
+
69
+ console.log(`ok: ${tools.length} tool(s), ${resources.length} resource(s), canvas html ${html.length} byte(s)`)
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { createServer } from 'node:net'
4
+
5
+ async function getFreePort() {
6
+ return new Promise((resolve, reject) => {
7
+ const server = createServer()
8
+ server.once('error', reject)
9
+ server.listen(0, '127.0.0.1', () => {
10
+ const address = server.address()
11
+ server.close(() => {
12
+ if (!address || typeof address === 'string') reject(new Error('Could not allocate port'))
13
+ else resolve(address.port)
14
+ })
15
+ })
16
+ })
17
+ }
18
+
19
+ function run(command, args, opts = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ const child = spawn(command, args, { stdio: 'inherit', ...opts })
22
+ child.on('error', reject)
23
+ child.on('exit', (code) => {
24
+ if (code === 0) resolve()
25
+ else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`))
26
+ })
27
+ })
28
+ }
29
+
30
+ function waitForExit(child, timeoutMs = 5000) {
31
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
32
+ return new Promise((resolve) => {
33
+ const timer = setTimeout(resolve, timeoutMs)
34
+ child.once('exit', () => {
35
+ clearTimeout(timer)
36
+ resolve()
37
+ })
38
+ })
39
+ }
40
+
41
+ async function waitForOptions(url, child) {
42
+ for (let i = 0; i < 120; i++) {
43
+ if (child.exitCode !== null) throw new Error(`packaged mcp-app exited with ${child.exitCode}`)
44
+ try {
45
+ const response = await fetch(url, { method: 'OPTIONS' })
46
+ if (response.ok || response.status === 204) return
47
+ } catch {}
48
+ await new Promise((resolve) => setTimeout(resolve, 500))
49
+ }
50
+ throw new Error(`packaged mcp-app did not become reachable: ${url}`)
51
+ }
52
+
53
+ const port = await getFreePort()
54
+ const endpoint = `http://127.0.0.1:${port}/mcp`
55
+ const app = spawn('yarn', ['-s', 'dev'], {
56
+ cwd: 'mcp-app',
57
+ stdio: 'inherit',
58
+ detached: true,
59
+ env: {
60
+ ...process.env,
61
+ TLDRAW_MCP_APP_PORT: String(port),
62
+ TLDRAW_MCP_URL: endpoint,
63
+ },
64
+ })
65
+
66
+ try {
67
+ await waitForOptions(endpoint, app)
68
+ await run(process.execPath, ['scripts/e2e-mcp.mjs'], {
69
+ env: { ...process.env, TLDRAW_MCP_URL: endpoint },
70
+ })
71
+ } finally {
72
+ try {
73
+ if (app.pid) process.kill(-app.pid, 'SIGTERM')
74
+ else app.kill('SIGTERM')
75
+ } catch {
76
+ app.kill('SIGTERM')
77
+ }
78
+ await waitForExit(app)
79
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const require = createRequire(import.meta.url)
8
+ const wranglerPkgPath = require.resolve('wrangler/package.json')
9
+ const wranglerPkg = require(wranglerPkgPath)
10
+ const wranglerBin = resolve(dirname(wranglerPkgPath), wranglerPkg.bin?.wrangler || './bin/wrangler.js')
11
+
12
+ const endpoint = new URL(process.env.TLDRAW_MCP_URL || 'http://127.0.0.1:8787/mcp')
13
+ const port = process.env.TLDRAW_MCP_APP_PORT || endpoint.port || '8787'
14
+ const origin = process.env.TLDRAW_MCP_WORKER_ORIGIN || `${endpoint.protocol}//${endpoint.hostname}:${port}`
15
+
16
+ const args = [
17
+ wranglerBin,
18
+ 'dev',
19
+ '--var',
20
+ 'MCP_IS_DEV:true',
21
+ '--var',
22
+ `WORKER_ORIGIN:${origin}`,
23
+ ...process.argv.slice(2),
24
+ ]
25
+
26
+ if (process.env.TLDRAW_MCP_APP_PORT && !args.includes('--port')) {
27
+ args.splice(2, 0, '--port', port)
28
+ }
29
+
30
+ const child = spawn(process.execPath, args, {
31
+ cwd: process.cwd(),
32
+ stdio: 'inherit',
33
+ env: process.env,
34
+ })
35
+
36
+ for (const signal of ['SIGINT', 'SIGTERM']) {
37
+ process.on(signal, () => {
38
+ child.kill(signal)
39
+ })
40
+ }
41
+
42
+ child.on('exit', (code) => {
43
+ process.exit(code ?? 0)
44
+ })
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
4
+ import { resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
8
+ const bundlePath = resolve(root, 'static/app-bridge-bundle.js')
9
+ const metaPath = resolve(root, 'static/app-bridge-bundle.meta.json')
10
+
11
+ function sha256(value) {
12
+ return createHash('sha256').update(value).digest('hex')
13
+ }
14
+
15
+ const [bundle, rawMeta] = await Promise.all([
16
+ readFile(bundlePath, 'utf8'),
17
+ readFile(metaPath, 'utf8'),
18
+ ])
19
+ const meta = JSON.parse(rawMeta)
20
+
21
+ const requiredSnippets = [
22
+ 'AppBridge',
23
+ 'PostMessageTransport',
24
+ 'McpAppsBridge',
25
+ ]
26
+
27
+ for (const snippet of requiredSnippets) {
28
+ if (!bundle.includes(snippet)) {
29
+ throw new Error(`Bridge bundle does not contain required snippet: ${snippet}`)
30
+ }
31
+ }
32
+
33
+ if (meta?.artifact !== 'static/app-bridge-bundle.js') {
34
+ throw new Error('Bridge bundle metadata has unexpected artifact path')
35
+ }
36
+
37
+ if (meta?.hashes?.bundleSha256 !== sha256(bundle)) {
38
+ throw new Error('Bridge bundle hash does not match metadata; run npm run build:bridge')
39
+ }
40
+
41
+ console.log('bridge bundle verified')
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { existsSync } from 'node:fs'
5
+ import { resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
9
+ const manifestPath = resolve(root, 'mcp-app-source.json')
10
+ const provenancePath = resolve(root, 'mcp-app/PI_TLDRAW_PROVENANCE.json')
11
+
12
+ function sha256(value) {
13
+ return createHash('sha256').update(value).digest('hex')
14
+ }
15
+
16
+ async function readJson(path) {
17
+ return JSON.parse(await readFile(path, 'utf8'))
18
+ }
19
+
20
+ if (!existsSync(manifestPath)) throw new Error('Missing mcp-app-source.json')
21
+ if (!existsSync(provenancePath)) throw new Error('Missing mcp-app/PI_TLDRAW_PROVENANCE.json')
22
+
23
+ const manifest = await readJson(manifestPath)
24
+ const provenance = await readJson(provenancePath)
25
+ const source = provenance.source ?? {}
26
+
27
+ for (const field of ['repo', 'commit', 'appPath']) {
28
+ if (!manifest[field]) throw new Error(`mcp-app-source.json missing ${field}`)
29
+ if (source[field] !== manifest[field]) {
30
+ throw new Error(`mcp-app provenance ${field} does not match manifest`)
31
+ }
32
+ }
33
+
34
+ const expectedPatches = []
35
+ for (const patchPath of manifest.patches ?? []) {
36
+ const absolutePath = resolve(root, patchPath)
37
+ if (!existsSync(absolutePath)) throw new Error(`Manifest patch does not exist: ${patchPath}`)
38
+ const body = await readFile(absolutePath, 'utf8')
39
+ expectedPatches.push({ path: patchPath, sha256: sha256(body) })
40
+ }
41
+
42
+ const actualPatches = source.patches ?? []
43
+ if (JSON.stringify(actualPatches) !== JSON.stringify(expectedPatches)) {
44
+ throw new Error('mcp-app provenance patches do not match manifest patch hashes')
45
+ }
46
+
47
+ for (const file of ['mcp-app.html', 'editor-api.json', 'method-map.json']) {
48
+ if (!provenance.dist?.[file]) throw new Error(`mcp-app provenance missing dist hash for ${file}`)
49
+ }
50
+
51
+ console.log('mcp-app source provenance verified')
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
8
+ const appDir = resolve(root, 'mcp-app')
9
+
10
+ const requiredFiles = [
11
+ 'package.json',
12
+ 'wrangler.toml',
13
+ 'src/worker.ts',
14
+ 'src/register-tools.ts',
15
+ 'dist/mcp-app.html',
16
+ 'dist/editor-api.json',
17
+ 'dist/method-map.json',
18
+ 'PI_TLDRAW_PROVENANCE.json',
19
+ ]
20
+
21
+ for (const file of requiredFiles) {
22
+ if (!existsSync(resolve(appDir, file))) {
23
+ throw new Error(`Packaged mcp-app is missing ${file}; run npm run assemble:mcp-app`)
24
+ }
25
+ }
26
+
27
+ const pkg = JSON.parse(await readFile(resolve(appDir, 'package.json'), 'utf8'))
28
+ const devScript = pkg.scripts?.['dev:http'] || ''
29
+ if (devScript.includes('yarn build') || devScript.includes('vite build')) {
30
+ throw new Error('Packaged mcp-app dev script must use prebuilt dist assets, not rebuild on user machines')
31
+ }
32
+
33
+ const html = await readFile(resolve(appDir, 'dist/mcp-app.html'), 'utf8')
34
+ if (!html.includes('<html') && !html.includes('<!doctype html')) {
35
+ throw new Error('Packaged mcp-app dist/mcp-app.html does not look like HTML')
36
+ }
37
+
38
+ console.log('packaged mcp-app verified')
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process'
3
+
4
+ const required = [
5
+ 'bridge/app-bridge-entry.js',
6
+ 'static/app-bridge-bundle.js',
7
+ 'static/app-bridge-bundle.meta.json',
8
+ 'mcp-app-source.json',
9
+ 'patches/tldraw-mcp-app/001-pi-runtime.patch',
10
+ 'mcp-app/PI_TLDRAW_PROVENANCE.json',
11
+ 'mcp-app/package.json',
12
+ 'mcp-app/wrangler.toml',
13
+ 'mcp-app/src/worker.ts',
14
+ 'mcp-app/src/register-tools.ts',
15
+ 'mcp-app/dist/mcp-app.html',
16
+ 'mcp-app/dist/editor-api.json',
17
+ 'mcp-app/dist/method-map.json',
18
+ 'scripts/run-mcp-app-dev.mjs',
19
+ ]
20
+
21
+ const forbiddenPrefixes = [
22
+ 'node_modules/',
23
+ 'mcp-app/node_modules/',
24
+ 'mcp-app/.wrangler/',
25
+ '.pi/',
26
+ ]
27
+
28
+ const result = spawnSync('npm', ['pack', '--dry-run', '--json'], {
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ })
32
+
33
+ if (result.status !== 0) {
34
+ throw new Error(`npm pack --dry-run --json failed\n${result.stdout}\n${result.stderr}`)
35
+ }
36
+
37
+ const packs = JSON.parse(result.stdout)
38
+ const files = new Set((packs[0]?.files ?? []).map((file) => file.path))
39
+
40
+ const missing = required.filter((path) => !files.has(path))
41
+ if (missing.length > 0) {
42
+ throw new Error(`Package tarball is missing required file(s):\n${missing.join('\n')}`)
43
+ }
44
+
45
+ const forbidden = [...files].filter((path) => forbiddenPrefixes.some((prefix) => path.startsWith(prefix)))
46
+ if (forbidden.length > 0) {
47
+ throw new Error(`Package tarball contains forbidden file(s):\n${forbidden.join('\n')}`)
48
+ }
49
+
50
+ console.log(`package files verified (${files.size} files)`)