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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.tldraw/tldraw",
|
|
4
|
+
"description": "Draw and visually collaborate with your agents on tldraw's canvas.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/tldraw/tldraw",
|
|
7
|
+
"source": "github",
|
|
8
|
+
"subfolder": "apps/mcp-app"
|
|
9
|
+
},
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"remotes": [
|
|
12
|
+
{
|
|
13
|
+
"type": "streamable-http",
|
|
14
|
+
"url": "https://tldraw-mcp-app.tldraw.workers.dev/mcp"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "sse",
|
|
18
|
+
"url": "https://tldraw-mcp-app.tldraw.workers.dev/sse"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logger for the tldraw MCP Durable Object.
|
|
3
|
+
*
|
|
4
|
+
* Outputs JSON lines to console.error so that
|
|
5
|
+
* Cloudflare Workers Observability can capture and query them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class Logger {
|
|
9
|
+
constructor(
|
|
10
|
+
private prefix: string,
|
|
11
|
+
private enabled: boolean
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
info(message: string, data?: Record<string, unknown>) {
|
|
15
|
+
if (!this.enabled) return
|
|
16
|
+
console.error(
|
|
17
|
+
JSON.stringify({ level: 'info', prefix: this.prefix, message, ...data, ts: Date.now() })
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
error(message: string, data?: Record<string, unknown>) {
|
|
22
|
+
if (!this.enabled) return
|
|
23
|
+
console.error(
|
|
24
|
+
JSON.stringify({ level: 'error', prefix: this.prefix, message, ...data, ts: Date.now() })
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
debug(message: string, data?: Record<string, unknown>) {
|
|
29
|
+
if (!this.enabled) return
|
|
30
|
+
console.error(
|
|
31
|
+
JSON.stringify({ level: 'debug', prefix: this.prefix, message, ...data, ts: Date.now() })
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
child(prefix: string): Logger {
|
|
36
|
+
return new Logger(`${this.prefix}:${prefix}`, this.enabled)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns a log function compatible with RegisterToolsOptions.log */
|
|
40
|
+
toLogFn(): (...args: unknown[]) => void {
|
|
41
|
+
return (...args: unknown[]) => {
|
|
42
|
+
this.info(args.map(String).join(' '))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'
|
|
2
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
3
|
+
import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { CANVAS_RESOURCE_URI } from './shared/types'
|
|
6
|
+
import type { RegisterToolsOptions, ServerDeps } from './shared/types'
|
|
7
|
+
import { errorResponse, parseTlShapes } from './shared/utils'
|
|
8
|
+
import { registerExecTool } from './tools/exec'
|
|
9
|
+
import { registerSearchTool } from './tools/search'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shared tool/resource registration logic for the MCP worker runtime.
|
|
13
|
+
*
|
|
14
|
+
* Tools:
|
|
15
|
+
* - search: Query the Editor API spec (server-side)
|
|
16
|
+
* - exec: Execute JS against the live editor in the widget
|
|
17
|
+
* - read_checkpoint: Read checkpoint data (app-only)
|
|
18
|
+
* - save_checkpoint: Save checkpoint data (app-only)
|
|
19
|
+
* - tldraw-canvas: Interactive canvas widget resource
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// --- Helpers ---
|
|
23
|
+
|
|
24
|
+
function injectBootstrapData(html: string, bootstrap: Record<string, unknown>): string {
|
|
25
|
+
const toBase64 =
|
|
26
|
+
typeof Buffer !== 'undefined' ? (s: string) => Buffer.from(s).toString('base64') : btoa
|
|
27
|
+
const encoded = toBase64(JSON.stringify(bootstrap))
|
|
28
|
+
const bootstrapScript = `<script>window.__TLDRAW_BOOTSTRAP__=JSON.parse(atob("${encoded}"))</script>`
|
|
29
|
+
const lastIdx = html.lastIndexOf('</head>')
|
|
30
|
+
if (lastIdx === -1) return html
|
|
31
|
+
return html.slice(0, lastIdx) + bootstrapScript + html.slice(lastIdx)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArrayJson(json: string, fieldName: string): unknown[] {
|
|
35
|
+
const parsed = JSON.parse(json)
|
|
36
|
+
if (!Array.isArray(parsed)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${fieldName} must be a JSON array string. Build an array first, then pass JSON.stringify(array).`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return parsed
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function getWidgetDomain(
|
|
45
|
+
hostName: string | undefined,
|
|
46
|
+
isDev: boolean,
|
|
47
|
+
workerOrigin?: string
|
|
48
|
+
): Promise<string | undefined> {
|
|
49
|
+
if (isDev) return undefined
|
|
50
|
+
if (hostName === 'chatgpt') return 'https://tldraw.com'
|
|
51
|
+
if (hostName === 'claude' && workerOrigin) {
|
|
52
|
+
const mcpUrl = new URL('/mcp', workerOrigin).toString()
|
|
53
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(mcpUrl))
|
|
54
|
+
const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'))
|
|
55
|
+
.join('')
|
|
56
|
+
.slice(0, 32)
|
|
57
|
+
return `${hash}.claudemcpcontent.com`
|
|
58
|
+
}
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Registration ---
|
|
63
|
+
|
|
64
|
+
export function registerTools(
|
|
65
|
+
server: McpServer,
|
|
66
|
+
deps: ServerDeps,
|
|
67
|
+
opts: RegisterToolsOptions
|
|
68
|
+
): void {
|
|
69
|
+
const log = opts.log ?? ((...args: unknown[]) => console.error(...args))
|
|
70
|
+
const analytics = opts.analytics
|
|
71
|
+
|
|
72
|
+
// --- search (server-side spec query) ---
|
|
73
|
+
|
|
74
|
+
registerSearchTool(server, {
|
|
75
|
+
analytics,
|
|
76
|
+
log,
|
|
77
|
+
loader: opts.searchWorkerLoader,
|
|
78
|
+
loadSpec: deps.loadEditorApiSpec,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// --- exec (client-side code execution) ---
|
|
82
|
+
|
|
83
|
+
let currentExecCanvasId: string | null = null
|
|
84
|
+
|
|
85
|
+
registerExecTool(server, {
|
|
86
|
+
analytics,
|
|
87
|
+
log,
|
|
88
|
+
pendingRequests: opts.pendingRequests,
|
|
89
|
+
setCurrentExecCanvasId: (id) => {
|
|
90
|
+
currentExecCanvasId = id
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// --- _exec_callback (app-only: widget resolves pending exec via callServerTool) ---
|
|
95
|
+
|
|
96
|
+
const execCallbackSchema = z.object({
|
|
97
|
+
channel: z.string(),
|
|
98
|
+
result: z
|
|
99
|
+
.object({
|
|
100
|
+
success: z.boolean(),
|
|
101
|
+
result: z.unknown().optional(),
|
|
102
|
+
error: z.string().optional(),
|
|
103
|
+
})
|
|
104
|
+
.optional(),
|
|
105
|
+
error: z.string().optional(),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
server.registerTool(
|
|
109
|
+
'_exec_callback',
|
|
110
|
+
{
|
|
111
|
+
title: 'Exec Callback',
|
|
112
|
+
description: 'App-only: widget calls this to resolve a pending exec request.',
|
|
113
|
+
inputSchema: execCallbackSchema,
|
|
114
|
+
annotations: {
|
|
115
|
+
readOnlyHint: false,
|
|
116
|
+
destructiveHint: false,
|
|
117
|
+
idempotentHint: false,
|
|
118
|
+
openWorldHint: false,
|
|
119
|
+
},
|
|
120
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
121
|
+
},
|
|
122
|
+
async ({
|
|
123
|
+
channel,
|
|
124
|
+
result,
|
|
125
|
+
error,
|
|
126
|
+
}: z.infer<typeof execCallbackSchema>): Promise<CallToolResult> => {
|
|
127
|
+
const handled = error
|
|
128
|
+
? opts.pendingRequests.reject(channel, error)
|
|
129
|
+
: opts.pendingRequests.resolve(channel, result)
|
|
130
|
+
|
|
131
|
+
if (!handled) {
|
|
132
|
+
log(`[tldraw-mcp] Ignoring exec callback for non-pending channel "${channel}"`)
|
|
133
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false }) }] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const canvasId = currentExecCanvasId
|
|
137
|
+
currentExecCanvasId = null
|
|
138
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, canvasId }) }] }
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// --- _get_canvas_state (app-only: widget fetches fork data by canvasId) ---
|
|
143
|
+
|
|
144
|
+
server.registerTool(
|
|
145
|
+
'_get_canvas_state',
|
|
146
|
+
{
|
|
147
|
+
title: 'Get Canvas State',
|
|
148
|
+
description: 'App-only: get the latest checkpoint for a canvas by its canvasId.',
|
|
149
|
+
inputSchema: z.object({ canvasId: z.string().min(1) }),
|
|
150
|
+
annotations: {
|
|
151
|
+
readOnlyHint: true,
|
|
152
|
+
destructiveHint: false,
|
|
153
|
+
idempotentHint: true,
|
|
154
|
+
openWorldHint: false,
|
|
155
|
+
},
|
|
156
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
157
|
+
},
|
|
158
|
+
async ({ canvasId }: { canvasId: string }): Promise<CallToolResult> => {
|
|
159
|
+
const checkpointId = deps.getCanvasCheckpointId(canvasId)
|
|
160
|
+
if (!checkpointId) {
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{ type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) },
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const checkpoint = deps.loadCheckpoint(checkpointId)
|
|
168
|
+
if (!checkpoint) {
|
|
169
|
+
return {
|
|
170
|
+
content: [
|
|
171
|
+
{ type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) },
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const shapes = parseTlShapes(checkpoint.shapes)
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: 'text',
|
|
180
|
+
text: JSON.stringify({
|
|
181
|
+
checkpointId,
|
|
182
|
+
shapes,
|
|
183
|
+
assets: checkpoint.assets,
|
|
184
|
+
bindings: checkpoint.bindings,
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// --- read_checkpoint (app-only) ---
|
|
193
|
+
|
|
194
|
+
server.registerTool(
|
|
195
|
+
'read_checkpoint',
|
|
196
|
+
{
|
|
197
|
+
title: 'Read Checkpoint',
|
|
198
|
+
description: 'App-only: read shapes from a checkpoint by ID.',
|
|
199
|
+
inputSchema: z.object({ checkpointId: z.string().min(1) }),
|
|
200
|
+
annotations: {
|
|
201
|
+
readOnlyHint: true,
|
|
202
|
+
destructiveHint: false,
|
|
203
|
+
idempotentHint: true,
|
|
204
|
+
openWorldHint: false,
|
|
205
|
+
},
|
|
206
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
207
|
+
},
|
|
208
|
+
async ({ checkpointId }: { checkpointId: string }): Promise<CallToolResult> => {
|
|
209
|
+
const checkpoint = deps.loadCheckpoint(checkpointId)
|
|
210
|
+
if (!checkpoint) {
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: 'text', text: 'Not found.' }],
|
|
213
|
+
structuredContent: {
|
|
214
|
+
sessionId: deps.getSessionId(),
|
|
215
|
+
shapes: [],
|
|
216
|
+
assets: [],
|
|
217
|
+
bindings: [],
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const shapes = parseTlShapes(checkpoint.shapes)
|
|
223
|
+
const assets = checkpoint.assets
|
|
224
|
+
const bindings = checkpoint.bindings
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: 'text', text: `${shapes.length} shape(s), ${assets.length} asset(s).` }],
|
|
228
|
+
structuredContent: {
|
|
229
|
+
sessionId: deps.getSessionId(),
|
|
230
|
+
shapes,
|
|
231
|
+
assets,
|
|
232
|
+
bindings,
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// --- save_checkpoint (app-only) ---
|
|
239
|
+
|
|
240
|
+
server.registerTool(
|
|
241
|
+
'save_checkpoint',
|
|
242
|
+
{
|
|
243
|
+
title: 'Save Checkpoint',
|
|
244
|
+
description:
|
|
245
|
+
'App-only: save shapes to a checkpoint (from user edits). shapesJson and assetsJson must be JSON array strings.',
|
|
246
|
+
inputSchema: z.object({
|
|
247
|
+
checkpointId: z.string().min(1),
|
|
248
|
+
shapesJson: z.string(),
|
|
249
|
+
assetsJson: z.string().optional(),
|
|
250
|
+
bindingsJson: z.string().optional(),
|
|
251
|
+
canvasId: z.string().optional(),
|
|
252
|
+
}),
|
|
253
|
+
annotations: {
|
|
254
|
+
readOnlyHint: false,
|
|
255
|
+
destructiveHint: false,
|
|
256
|
+
idempotentHint: false,
|
|
257
|
+
openWorldHint: false,
|
|
258
|
+
},
|
|
259
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
260
|
+
},
|
|
261
|
+
async ({
|
|
262
|
+
checkpointId,
|
|
263
|
+
shapesJson,
|
|
264
|
+
assetsJson,
|
|
265
|
+
bindingsJson,
|
|
266
|
+
canvasId,
|
|
267
|
+
}: {
|
|
268
|
+
checkpointId: string
|
|
269
|
+
shapesJson: string
|
|
270
|
+
assetsJson?: string
|
|
271
|
+
bindingsJson?: string
|
|
272
|
+
canvasId?: string
|
|
273
|
+
}): Promise<CallToolResult> => {
|
|
274
|
+
try {
|
|
275
|
+
log(
|
|
276
|
+
`[tldraw-mcp] save_checkpoint called: checkpointId=${checkpointId}, canvasId=${canvasId ?? 'none'}, prev activeCheckpointId=${deps.getActiveCheckpointId()}`
|
|
277
|
+
)
|
|
278
|
+
const raw = parseArrayJson(shapesJson, 'shapesJson')
|
|
279
|
+
const shapes = parseTlShapes(raw)
|
|
280
|
+
const assets = assetsJson ? parseArrayJson(assetsJson, 'assetsJson') : []
|
|
281
|
+
const bindings = bindingsJson ? parseArrayJson(bindingsJson, 'bindingsJson') : []
|
|
282
|
+
deps.saveCheckpoint(checkpointId, shapes, assets, bindings)
|
|
283
|
+
deps.setActiveCheckpointId(checkpointId)
|
|
284
|
+
if (canvasId) {
|
|
285
|
+
deps.setCanvasCheckpointId(canvasId, checkpointId)
|
|
286
|
+
}
|
|
287
|
+
log(
|
|
288
|
+
`[tldraw-mcp] save_checkpoint done: activeCheckpointId=${deps.getActiveCheckpointId()}, canvasId=${canvasId ?? 'none'}, shapes=${shapes.length}, assets=${assets.length}`
|
|
289
|
+
)
|
|
290
|
+
return {
|
|
291
|
+
content: [
|
|
292
|
+
{ type: 'text', text: `Saved ${shapes.length} shape(s), ${assets.length} asset(s).` },
|
|
293
|
+
],
|
|
294
|
+
structuredContent: {
|
|
295
|
+
checkpointId,
|
|
296
|
+
sessionId: deps.getSessionId(),
|
|
297
|
+
shapesCount: shapes.length,
|
|
298
|
+
assetsCount: assets.length,
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return errorResponse('save_checkpoint', err)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
// --- canvas resource ---
|
|
308
|
+
|
|
309
|
+
registerAppResource(
|
|
310
|
+
server,
|
|
311
|
+
'tldraw-canvas',
|
|
312
|
+
CANVAS_RESOURCE_URI,
|
|
313
|
+
{
|
|
314
|
+
title: 'tldraw Canvas',
|
|
315
|
+
description: 'Interactive tldraw canvas.',
|
|
316
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
317
|
+
},
|
|
318
|
+
async (): Promise<ReadResourceResult> => {
|
|
319
|
+
analytics?.writeDataPoint({
|
|
320
|
+
blobs: ['resource_called', 'tldraw-canvas'],
|
|
321
|
+
})
|
|
322
|
+
let html = await deps.loadWidgetHtml()
|
|
323
|
+
|
|
324
|
+
const sid = deps.getSessionId()
|
|
325
|
+
const hostName = opts.getClientHostName()
|
|
326
|
+
|
|
327
|
+
const bootstrap: Record<string, unknown> = {
|
|
328
|
+
sessionId: sid,
|
|
329
|
+
isDev: opts.isDev,
|
|
330
|
+
workerOrigin: opts.workerOrigin,
|
|
331
|
+
mcpSessionId: deps.getMcpSessionId(),
|
|
332
|
+
methodMap: await deps.loadMethodMap(),
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
html = injectBootstrapData(html, bootstrap)
|
|
336
|
+
|
|
337
|
+
const domain = await getWidgetDomain(hostName, opts.isDev, opts.workerOrigin)
|
|
338
|
+
|
|
339
|
+
log(`[tldraw-mcp] Serving resource to "${hostName}" with domain: ${domain}`)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
contents: [
|
|
343
|
+
{
|
|
344
|
+
uri: CANVAS_RESOURCE_URI,
|
|
345
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
346
|
+
text: html,
|
|
347
|
+
_meta: {
|
|
348
|
+
ui: {
|
|
349
|
+
csp: {
|
|
350
|
+
resourceDomains: [
|
|
351
|
+
'https://cdn.tldraw.com',
|
|
352
|
+
'https://fonts.googleapis.com',
|
|
353
|
+
'https://fonts.gstatic.com',
|
|
354
|
+
...(opts.extraResourceDomains ?? []),
|
|
355
|
+
'blob:',
|
|
356
|
+
],
|
|
357
|
+
connectDomains: opts.extraConnectDomains ?? [],
|
|
358
|
+
},
|
|
359
|
+
permissions: { clipboardWrite: {} },
|
|
360
|
+
...(domain ? { domain } : {}),
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
type ArgKind =
|
|
2
|
+
| 'id'
|
|
3
|
+
| 'id-or-shape'
|
|
4
|
+
| 'ids-or-shapes'
|
|
5
|
+
| 'spread-ids'
|
|
6
|
+
| 'shape-partial'
|
|
7
|
+
| 'shape-partials'
|
|
8
|
+
| 'update-partial'
|
|
9
|
+
| 'update-partials'
|
|
10
|
+
|
|
11
|
+
export type RetKind =
|
|
12
|
+
| 'this'
|
|
13
|
+
| 'shape'
|
|
14
|
+
| 'shape-or-null'
|
|
15
|
+
| 'shapes'
|
|
16
|
+
| 'id'
|
|
17
|
+
| 'id-or-null'
|
|
18
|
+
| 'ids'
|
|
19
|
+
| 'id-set'
|
|
20
|
+
|
|
21
|
+
interface MethodSpec {
|
|
22
|
+
args: ArgKind[]
|
|
23
|
+
ret: RetKind
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MethodMap = Record<string, MethodSpec>
|
|
27
|
+
|
|
28
|
+
export interface EditorApiSpec {
|
|
29
|
+
extractedAt: string
|
|
30
|
+
memberCount: number
|
|
31
|
+
categories: string[]
|
|
32
|
+
members: unknown[]
|
|
33
|
+
types: {
|
|
34
|
+
shapeTypes: string[]
|
|
35
|
+
shapes: unknown[]
|
|
36
|
+
}
|
|
37
|
+
helperCount: number
|
|
38
|
+
helpers: unknown[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface AssetFetcher {
|
|
42
|
+
fetch(input: Request): Promise<Response>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const GENERATED_ASSET_BASE_URL = 'https://assets.local/'
|
|
46
|
+
|
|
47
|
+
let cachedEmbeddedMethodMap: MethodMap | null | undefined
|
|
48
|
+
|
|
49
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseMethodMap(value: unknown): MethodMap {
|
|
54
|
+
if (!isPlainObject(value)) {
|
|
55
|
+
throw new Error('Generated method map is missing or invalid.')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const entries = Object.entries(value).map(([methodName, spec]) => {
|
|
59
|
+
if (!isPlainObject(spec) || !Array.isArray(spec.args) || typeof spec.ret !== 'string') {
|
|
60
|
+
throw new Error(`Generated method map entry "${methodName}" is invalid.`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
methodName,
|
|
65
|
+
{
|
|
66
|
+
args: spec.args as ArgKind[],
|
|
67
|
+
ret: spec.ret as RetKind,
|
|
68
|
+
},
|
|
69
|
+
] as const
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return Object.fromEntries(entries)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseEditorApiSpec(value: unknown): EditorApiSpec {
|
|
76
|
+
if (!isPlainObject(value)) {
|
|
77
|
+
throw new Error('Generated editor API spec is missing or invalid.')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (
|
|
81
|
+
!Array.isArray(value.categories) ||
|
|
82
|
+
!Array.isArray(value.members) ||
|
|
83
|
+
!isPlainObject(value.types) ||
|
|
84
|
+
!Array.isArray(value.types.shapeTypes) ||
|
|
85
|
+
!Array.isArray(value.types.shapes) ||
|
|
86
|
+
!Array.isArray(value.helpers)
|
|
87
|
+
) {
|
|
88
|
+
throw new Error('Generated editor API spec is malformed.')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
extractedAt: typeof value.extractedAt === 'string' ? value.extractedAt : '',
|
|
93
|
+
memberCount: typeof value.memberCount === 'number' ? value.memberCount : value.members.length,
|
|
94
|
+
categories: value.categories.filter(
|
|
95
|
+
(category): category is string => typeof category === 'string'
|
|
96
|
+
),
|
|
97
|
+
members: value.members,
|
|
98
|
+
types: {
|
|
99
|
+
shapeTypes: value.types.shapeTypes.filter(
|
|
100
|
+
(shapeType): shapeType is string => typeof shapeType === 'string'
|
|
101
|
+
),
|
|
102
|
+
shapes: value.types.shapes,
|
|
103
|
+
},
|
|
104
|
+
helperCount: typeof value.helperCount === 'number' ? value.helperCount : value.helpers.length,
|
|
105
|
+
helpers: value.helpers,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function loadGeneratedJsonFromAssets<T>(
|
|
110
|
+
assets: AssetFetcher,
|
|
111
|
+
filename: string,
|
|
112
|
+
parser: (value: unknown) => T
|
|
113
|
+
): Promise<T> {
|
|
114
|
+
const response = await assets.fetch(new Request(new URL(filename, GENERATED_ASSET_BASE_URL)))
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new Error(`Failed to load generated asset "${filename}": ${response.status}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return parser(await response.json())
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readMethodMapFromBootstrap(): MethodMap | null {
|
|
123
|
+
if (typeof window === 'undefined') return null
|
|
124
|
+
|
|
125
|
+
const bootstrap = (window as Window & { __TLDRAW_BOOTSTRAP__?: unknown }).__TLDRAW_BOOTSTRAP__
|
|
126
|
+
if (!isPlainObject(bootstrap) || !('methodMap' in bootstrap)) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return parseMethodMap(bootstrap.methodMap)
|
|
132
|
+
} catch {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function loadEditorApiSpecFromAssets(assets: AssetFetcher): Promise<EditorApiSpec> {
|
|
138
|
+
return loadGeneratedJsonFromAssets(assets, 'editor-api.json', parseEditorApiSpec)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function loadMethodMapFromAssets(assets: AssetFetcher): Promise<MethodMap> {
|
|
142
|
+
return loadGeneratedJsonFromAssets(assets, 'method-map.json', parseMethodMap)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function primeEmbeddedMethodMap(): void {
|
|
146
|
+
if (cachedEmbeddedMethodMap !== undefined) return
|
|
147
|
+
cachedEmbeddedMethodMap = readMethodMapFromBootstrap()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getRequiredEmbeddedMethodMap(): MethodMap {
|
|
151
|
+
if (cachedEmbeddedMethodMap === undefined) {
|
|
152
|
+
cachedEmbeddedMethodMap = readMethodMapFromBootstrap()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!cachedEmbeddedMethodMap) {
|
|
156
|
+
throw new Error('Missing embedded method map. Rebuild the widget assets and reload the app.')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return cachedEmbeddedMethodMap
|
|
160
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic pending-request store for bridging async server↔widget communication.
|
|
3
|
+
*
|
|
4
|
+
* One pending request per named channel. The server creates a pending request
|
|
5
|
+
* and awaits it; the widget resolves or rejects it via `callServerTool('_exec_callback')`.
|
|
6
|
+
*
|
|
7
|
+
* Callbacks without an active pending request are ignored. This prevents late
|
|
8
|
+
* duplicate callbacks from being replayed into a later request on the same channel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface PendingEntry {
|
|
12
|
+
resolve(value: unknown): void
|
|
13
|
+
reject(reason: Error): void
|
|
14
|
+
timer: ReturnType<typeof setTimeout>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class PendingRequests {
|
|
18
|
+
private pending = new Map<string, PendingEntry>()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a pending request for the given channel.
|
|
22
|
+
* Returns a promise that resolves when `resolve()` is called,
|
|
23
|
+
* or rejects on timeout or if `reject()` is called.
|
|
24
|
+
*
|
|
25
|
+
* Throws if a request is already pending for this channel.
|
|
26
|
+
*/
|
|
27
|
+
create(channel: string, timeoutMs = 30_000): Promise<unknown> {
|
|
28
|
+
if (this.pending.has(channel)) {
|
|
29
|
+
throw new Error(`A request is already pending for channel "${channel}"`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
this.pending.delete(channel)
|
|
35
|
+
reject(new Error(`Callback timed out after ${timeoutMs}ms for channel "${channel}"`))
|
|
36
|
+
}, timeoutMs)
|
|
37
|
+
|
|
38
|
+
this.pending.set(channel, { resolve, reject, timer })
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the pending request for the given channel.
|
|
44
|
+
* Returns false if no request is currently pending.
|
|
45
|
+
*/
|
|
46
|
+
resolve(channel: string, value: unknown): boolean {
|
|
47
|
+
const entry = this.pending.get(channel)
|
|
48
|
+
if (!entry) return false
|
|
49
|
+
|
|
50
|
+
clearTimeout(entry.timer)
|
|
51
|
+
this.pending.delete(channel)
|
|
52
|
+
entry.resolve(value)
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reject the pending request for the given channel.
|
|
58
|
+
* Returns false if no request is currently pending.
|
|
59
|
+
*/
|
|
60
|
+
reject(channel: string, error: string): boolean {
|
|
61
|
+
const entry = this.pending.get(channel)
|
|
62
|
+
if (!entry) return false
|
|
63
|
+
|
|
64
|
+
clearTimeout(entry.timer)
|
|
65
|
+
this.pending.delete(channel)
|
|
66
|
+
entry.reject(new Error(error))
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
}
|