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,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)`)
|