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,76 @@
1
+ import type { TLShape } from 'tldraw'
2
+ import packageJson from '../../package.json'
3
+ import type { EditorApiSpec, MethodMap } from './generated-data'
4
+ import type { PendingRequests } from './pending-requests'
5
+
6
+ export interface PendingBootstrap {
7
+ canvasId: string
8
+ checkpointId: string | null
9
+ }
10
+
11
+ export interface ServerDeps {
12
+ saveCheckpoint(id: string, shapes: TLShape[], assets?: unknown[], bindings?: unknown[]): void
13
+ loadCheckpoint(id: string): { shapes: unknown[]; assets: unknown[]; bindings: unknown[] } | null
14
+ getActiveCheckpointId(): string | null
15
+ setActiveCheckpointId(id: string): void
16
+ getCanvasCheckpointId(canvasId: string): string | null
17
+ setCanvasCheckpointId(canvasId: string, checkpointId: string): void
18
+ setPendingBootstrap(bootstrap: PendingBootstrap): void
19
+ consumePendingBootstrap(): PendingBootstrap | null
20
+ getSessionId(): string
21
+ getMcpSessionId(): string
22
+ loadWidgetHtml(): Promise<string>
23
+ loadEditorApiSpec(): Promise<EditorApiSpec>
24
+ loadMethodMap(): Promise<MethodMap>
25
+ }
26
+
27
+ export interface RegisterToolsOptions {
28
+ /** Extra CSP resource domains (e.g. R2 image URLs). */
29
+ extraResourceDomains?: string[]
30
+ /** Extra CSP connect domains. */
31
+ extraConnectDomains?: string[]
32
+ /** Dynamic Workers loader for sandboxed server-side code execution. */
33
+ searchWorkerLoader: DynamicWorkerLoader
34
+ /** Public origin of the deployed MCP worker, used for host-specific widget domains. */
35
+ workerOrigin?: string
36
+ /** Flag so the tools, and thus the widget, know if they are running in dev mode. */
37
+ isDev: boolean
38
+ /** Logging function (defaults to console.error). */
39
+ log?(...args: unknown[]): void
40
+ /** Analytics engine dataset. */
41
+ analytics?: AnalyticsEngineDataset
42
+ /** Returns the resolved host name of the connected client. */
43
+ getClientHostName(): MCP_APP_HOST_NAMES | undefined
44
+ /** Pending requests store for widget→server callback bridge. */
45
+ pendingRequests: PendingRequests
46
+ }
47
+
48
+ export interface DynamicWorkerLoader {
49
+ load(code: {
50
+ compatibilityDate: string
51
+ mainModule: string
52
+ modules: Record<string, string | { js: string }>
53
+ env?: unknown
54
+ globalOutbound?: null
55
+ }): {
56
+ getEntrypoint(name?: string): unknown
57
+ }
58
+ }
59
+
60
+ export const MCP_SERVER_NAME = 'tldraw'
61
+ export const MCP_SERVER_VERSION = packageJson.version
62
+ export const MCP_SERVER_TITLE = 'tldraw Canvas'
63
+ export const MCP_SERVER_DESCRIPTION =
64
+ 'An interactive tldraw canvas with tools for diagramming, drawing, and more.'
65
+ export const MCP_SERVER_WEBSITE_URL = 'https://www.tldraw.com'
66
+ export const MCP_SERVER_INSTRUCTIONS =
67
+ 'Use `search` to query the tldraw Editor API spec (e.g. search for methods by category or name). Use `exec` to run JavaScript on the canvas — your code receives `editor` (the tldraw Editor instance) and helpers like toRichText, createShapeId, createArrowBetweenShapes. The current canvas state is kept in model context as raw TLShape, asset, and binding data.'
68
+
69
+ export const CANVAS_RESOURCE_URI = 'ui://show-canvas/mcp-app.html'
70
+
71
+ /** Must match `compatibility_date` in wrangler.toml. */
72
+ export const WORKER_COMPATIBILITY_DATE = '2025-03-10'
73
+
74
+ export const MAX_CHECKPOINTS = 200
75
+
76
+ export type MCP_APP_HOST_NAMES = 'cursor' | 'vscode' | 'claude' | 'chatgpt'
@@ -0,0 +1,132 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
2
+ import type { TLShape } from 'tldraw'
3
+ import type { MCP_APP_HOST_NAMES } from './types'
4
+
5
+ const CANVAS_ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789'
6
+ const CANVAS_ID_LENGTH = 8
7
+
8
+ export function generateCanvasId(): string {
9
+ const bytes = new Uint8Array(CANVAS_ID_LENGTH)
10
+ crypto.getRandomValues(bytes)
11
+ return Array.from(bytes, (b) => CANVAS_ID_CHARS[b % CANVAS_ID_CHARS.length]).join('')
12
+ }
13
+
14
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
16
+ }
17
+
18
+ export function parseTlShapes(value: unknown[]): TLShape[] {
19
+ return value.filter(
20
+ (s): s is TLShape => isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
21
+ )
22
+ }
23
+
24
+ export function errorResponse(toolName: string, err: unknown, hint?: string): CallToolResult {
25
+ const message = err instanceof Error ? err.message : String(err)
26
+ const parts = [`[${toolName}] Error: ${message}`]
27
+ if (hint) parts.push(hint)
28
+ return {
29
+ content: [{ type: 'text', text: parts.join('\n\n') }],
30
+ isError: true,
31
+ }
32
+ }
33
+
34
+ // these are what we get from server.server.getClientVersion() in the worker
35
+ export function resolveMcpAppHostNameFromServerInfo(
36
+ potentialHostName: string
37
+ ): MCP_APP_HOST_NAMES | undefined {
38
+ const normalizedPotentialHostName = potentialHostName.trim().toLowerCase()
39
+
40
+ if (normalizedPotentialHostName.includes('cursor-vscode')) return 'cursor' // we expect something like "cursor-vscode (via mcp-remote 0.1.37)"
41
+ if (normalizedPotentialHostName.includes('visual studio code')) return 'vscode' // we expect something like "Visual Studio Code (via mcp-remote 0.1.37)"
42
+ if (
43
+ normalizedPotentialHostName.includes('openai-mcp') ||
44
+ normalizedPotentialHostName.includes('chatgpt')
45
+ )
46
+ return 'chatgpt' // we expect something like "openai-mcp"
47
+ if (normalizedPotentialHostName.includes('claude-ai')) return 'claude' // we expect something like "claude-ai (via mcp-remote 0.1.37)"
48
+
49
+ return undefined
50
+ }
51
+
52
+ // these are what we expect from app.getHostVersion() (called in the client)
53
+ export function resolveMcpAppHostNameFromClientInfo(
54
+ potentialHostName: string
55
+ ): MCP_APP_HOST_NAMES | undefined {
56
+ const normalizedPotentialHostName = potentialHostName.trim().toLowerCase()
57
+
58
+ if (normalizedPotentialHostName.includes('cursor')) return 'cursor'
59
+ if (normalizedPotentialHostName.includes('visual studio code')) return 'vscode'
60
+ if (normalizedPotentialHostName.includes('chatgpt')) return 'chatgpt'
61
+ if (normalizedPotentialHostName.includes('claude')) return 'claude'
62
+
63
+ return undefined
64
+ }
65
+
66
+ const CODE_EDITOR_HOST_NAMES: MCP_APP_HOST_NAMES[] = ['cursor', 'vscode']
67
+ const ANALYTICS_ENGINE_MAX_BLOB_BYTES = 16 * 1024
68
+ const TRUNCATED_ANALYTICS_SUFFIX = '...[truncated]'
69
+
70
+ export function isHostCodeEditor(hostName: MCP_APP_HOST_NAMES): boolean {
71
+ return CODE_EDITOR_HOST_NAMES.includes(hostName)
72
+ }
73
+
74
+ function truncateUtf8String(value: string, maxBytes: number): string {
75
+ const encoder = new TextEncoder()
76
+ if (maxBytes <= 0) return ''
77
+ if (encoder.encode(value).byteLength <= maxBytes) return value
78
+
79
+ const suffixByteLength = encoder.encode(TRUNCATED_ANALYTICS_SUFFIX).byteLength
80
+ if (suffixByteLength >= maxBytes) {
81
+ let low = 0
82
+ let high = TRUNCATED_ANALYTICS_SUFFIX.length
83
+
84
+ while (low < high) {
85
+ const mid = Math.ceil((low + high) / 2)
86
+ if (encoder.encode(TRUNCATED_ANALYTICS_SUFFIX.slice(0, mid)).byteLength <= maxBytes) {
87
+ low = mid
88
+ } else {
89
+ high = mid - 1
90
+ }
91
+ }
92
+
93
+ return TRUNCATED_ANALYTICS_SUFFIX.slice(0, low)
94
+ }
95
+
96
+ const maxValueBytes = maxBytes - suffixByteLength
97
+ let low = 0
98
+ let high = value.length
99
+
100
+ while (low < high) {
101
+ const mid = Math.ceil((low + high) / 2)
102
+ if (encoder.encode(value.slice(0, mid)).byteLength <= maxValueBytes) {
103
+ low = mid
104
+ } else {
105
+ high = mid - 1
106
+ }
107
+ }
108
+
109
+ return `${value.slice(0, low)}${TRUNCATED_ANALYTICS_SUFFIX}`
110
+ }
111
+
112
+ export function writeToolAnalytics(
113
+ analytics: AnalyticsEngineDataset | undefined,
114
+ toolName: string,
115
+ code: string
116
+ ) {
117
+ if (!analytics) return
118
+
119
+ const encoder = new TextEncoder()
120
+ const baseBlobs = ['tool_called', toolName]
121
+ const maxCodeBytes =
122
+ ANALYTICS_ENGINE_MAX_BLOB_BYTES -
123
+ baseBlobs.reduce((total, blob) => total + encoder.encode(blob).byteLength, 0)
124
+
125
+ try {
126
+ analytics.writeDataPoint({
127
+ blobs: [...baseBlobs, truncateUtf8String(code, maxCodeBytes)],
128
+ })
129
+ } catch {
130
+ // writeDataPoint returns immediately and never throws, so we won't know if it failed
131
+ }
132
+ }
@@ -0,0 +1,120 @@
1
+ import { registerAppTool } from '@modelcontextprotocol/ext-apps/server'
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
4
+ import { z } from 'zod'
5
+ import type { PendingRequests } from '../shared/pending-requests'
6
+ import { CANVAS_RESOURCE_URI } from '../shared/types'
7
+ import { generateCanvasId, writeToolAnalytics } from '../shared/utils'
8
+
9
+ const EXEC_CALLBACK_TIMEOUT_MS = 30_000
10
+
11
+ export function registerExecTool(
12
+ server: McpServer,
13
+ opts: {
14
+ analytics?: AnalyticsEngineDataset
15
+ log(...args: unknown[]): void
16
+ pendingRequests: PendingRequests
17
+ setCurrentExecCanvasId(id: string): void
18
+ }
19
+ ) {
20
+ registerAppTool(
21
+ server,
22
+ 'exec',
23
+ {
24
+ title: 'Execute Code',
25
+ description: `Execute JavaScript code on a tldraw canvas. The code runs in the widget with access to the live \`editor\` instance, helper functions, and normal js. Use the \`search\` tool first to discover available Editor methods and shape types.
26
+
27
+ Each canvas has a unique \`canvasId\`. Omit \`canvasId\` to create a new blank canvas. To edit an existing canvas, pass the \`canvasId\` that was returned by a previous exec call.
28
+
29
+ Shapes and text grow depending on the amount of text they have. Use clever scripting to ensure there are no unintended overlaps.
30
+
31
+ Examples:
32
+ - Create a rectangle: editor.createShape({ _type: 'rectangle', shapeId: 'box1', x: 200, y: 120, w: 320, h: 180, text: 'Hello' })
33
+ - Connect shapes with an arrow: editor.createShape({ _type: 'arrow', shapeId: 'a1', fromId: 'box1', toId: 'box2', x1: 0, y1: 0, x2: 100, y2: 0 })
34
+ - Select and zoom: editor.select('box1'); editor.zoomToSelection()
35
+ - Read shapes: return editor.getCurrentPageShapes()
36
+ - Distribute evenly: editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
37
+ - Box around shapes: boxShapes(['box1', 'box2'], { text: 'Group label', color: 'blue' })
38
+ - Stack shapes dynamically: editor.createShape({ _type: 'rectangle', shapeId: 'a', x: 0, y: 0, w: 300, h: 200, text: 'First box\\nwith wrapping text' }); const bounds = editor.getShapePageBounds('a'); editor.createShape({ _type: 'rectangle', shapeId: 'b', x: 0, y: bounds.maxY + 20, w: 300, h: 200, text: 'Below first' })`,
39
+ inputSchema: z.object({
40
+ code: z
41
+ .string()
42
+ .describe(
43
+ 'JavaScript code to execute. Has access to `editor` (tldraw Editor instance) and helper functions.'
44
+ ),
45
+ canvasId: z
46
+ .string()
47
+ .optional()
48
+ .describe(
49
+ 'Canvas ID to edit. Omit to create a new blank canvas. Pass a canvasId from a previous exec result to continue editing that canvas.'
50
+ ),
51
+ }),
52
+ annotations: {
53
+ readOnlyHint: false,
54
+ destructiveHint: false,
55
+ idempotentHint: false,
56
+ openWorldHint: false,
57
+ },
58
+ _meta: { ui: { resourceUri: CANVAS_RESOURCE_URI } },
59
+ },
60
+ async ({
61
+ code,
62
+ canvasId: inputCanvasId,
63
+ }: {
64
+ code: string
65
+ canvasId?: string
66
+ }): Promise<CallToolResult> => {
67
+ writeToolAnalytics(opts.analytics, 'exec', code)
68
+
69
+ const canvasId = inputCanvasId || generateCanvasId()
70
+ opts.setCurrentExecCanvasId(canvasId)
71
+
72
+ opts.log(`[tldraw-mcp] exec called: canvasId=${canvasId}, existing=${Boolean(inputCanvasId)}`)
73
+
74
+ try {
75
+ const result = (await opts.pendingRequests.create('exec', EXEC_CALLBACK_TIMEOUT_MS)) as {
76
+ success: boolean
77
+ result?: unknown
78
+ error?: string
79
+ }
80
+
81
+ if (!result.success) {
82
+ opts.log(`[tldraw-mcp] exec failed: ${result.error}`)
83
+ return {
84
+ content: [
85
+ {
86
+ type: 'text',
87
+ text: `Runtime error executing code on canvas. The code was NOT applied successfully. Fix the error and try again.\n\nCanvas ID: ${canvasId} — to retry on this canvas, pass this canvasId.\n\nError: ${result.error}`,
88
+ },
89
+ ],
90
+ isError: true,
91
+ }
92
+ }
93
+
94
+ const resultStr =
95
+ result.result !== undefined ? JSON.stringify(result.result, null, 2) : undefined
96
+ const lines = [
97
+ resultStr
98
+ ? `Code executed successfully on canvas. Return value:\n${resultStr}`
99
+ : 'Code executed successfully on canvas.',
100
+ `\nCanvas ID: ${canvasId} — to edit this canvas again, pass this as the canvasId parameter.`,
101
+ ]
102
+
103
+ opts.log(`[tldraw-mcp] exec succeeded, canvasId=${canvasId}`)
104
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
105
+ } catch (err) {
106
+ const message = err instanceof Error ? err.message : String(err)
107
+ opts.log(`[tldraw-mcp] exec error: ${message}`)
108
+ return {
109
+ content: [
110
+ {
111
+ type: 'text',
112
+ text: `Exec failed: ${message}`,
113
+ },
114
+ ],
115
+ isError: true,
116
+ }
117
+ }
118
+ }
119
+ )
120
+ }
@@ -0,0 +1,16 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __filename = fileURLToPath(import.meta.url)
6
+ const __dirname = path.dirname(__filename)
7
+ const DIST_DIR = path.join(__dirname, '..', '..', 'dist')
8
+
9
+ let cachedHtml: string | null = null
10
+
11
+ export async function loadCachedCanvasWidgetHtml(): Promise<string> {
12
+ if (cachedHtml) return cachedHtml
13
+ const html = await fs.readFile(path.join(DIST_DIR, 'mcp-app.html'), 'utf-8')
14
+ cachedHtml = html
15
+ return html
16
+ }
@@ -0,0 +1,150 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
3
+ import { z } from 'zod'
4
+ import type { EditorApiSpec } from '../shared/generated-data'
5
+ import { WORKER_COMPATIBILITY_DATE } from '../shared/types'
6
+ import type { DynamicWorkerLoader } from '../shared/types'
7
+ import { writeToolAnalytics } from '../shared/utils'
8
+
9
+ const SEARCH_TIMEOUT_MS = 5_000
10
+ const SEARCH_RUNNER_MODULE = 'search-runner.js'
11
+ const SEARCH_RUNNER_ENTRYPOINT = 'SearchRunner'
12
+
13
+ type SearchWorkerResult = { success: true; value: unknown } | { success: false; error: string }
14
+ type SearchSpec = Pick<
15
+ EditorApiSpec,
16
+ 'members' | 'categories' | 'types' | 'helperCount' | 'helpers'
17
+ >
18
+
19
+ function createSearchRunnerModule(code: string) {
20
+ return `import { WorkerEntrypoint } from 'cloudflare:workers'
21
+
22
+ function serializeResult(result) {
23
+ try {
24
+ return JSON.parse(JSON.stringify(result))
25
+ } catch {
26
+ return String(result)
27
+ }
28
+ }
29
+
30
+ export class ${SEARCH_RUNNER_ENTRYPOINT} extends WorkerEntrypoint {
31
+ async run() {
32
+ try {
33
+ const spec = this.env.SPEC
34
+ const result = await (async () => {
35
+ ${code}
36
+ })()
37
+ return { success: true, value: serializeResult(result) }
38
+ } catch (err) {
39
+ return {
40
+ success: false,
41
+ error: err instanceof Error ? err.message : String(err),
42
+ }
43
+ }
44
+ }
45
+ }
46
+ `
47
+ }
48
+
49
+ async function runSearchInDynamicWorker(
50
+ loader: DynamicWorkerLoader,
51
+ spec: SearchSpec,
52
+ code: string
53
+ ) {
54
+ const worker = loader.load({
55
+ compatibilityDate: WORKER_COMPATIBILITY_DATE,
56
+ mainModule: SEARCH_RUNNER_MODULE,
57
+ modules: {
58
+ [SEARCH_RUNNER_MODULE]: {
59
+ js: createSearchRunnerModule(code),
60
+ },
61
+ },
62
+ env: {
63
+ SPEC: spec,
64
+ },
65
+ globalOutbound: null,
66
+ })
67
+
68
+ const entrypoint = worker.getEntrypoint(SEARCH_RUNNER_ENTRYPOINT) as {
69
+ run(): Promise<SearchWorkerResult>
70
+ }
71
+ const result = await Promise.race([
72
+ entrypoint.run(),
73
+ new Promise<never>((_, reject) =>
74
+ setTimeout(
75
+ () => reject(new Error(`Search timed out after ${SEARCH_TIMEOUT_MS}ms`)),
76
+ SEARCH_TIMEOUT_MS
77
+ )
78
+ ),
79
+ ])
80
+
81
+ if (!result.success) {
82
+ throw new Error(result.error)
83
+ }
84
+
85
+ return result.value
86
+ }
87
+
88
+ export function registerSearchTool(
89
+ server: McpServer,
90
+ opts: {
91
+ analytics?: AnalyticsEngineDataset
92
+ loader: DynamicWorkerLoader
93
+ loadSpec(): Promise<EditorApiSpec>
94
+ log(...args: unknown[]): void
95
+ }
96
+ ) {
97
+ let specPromise: Promise<SearchSpec> | null = null
98
+
99
+ server.registerTool(
100
+ 'search',
101
+ {
102
+ title: 'Search Editor API',
103
+ description: `Search the tldraw Editor API spec by writing JavaScript that receives a \`spec\` object and returns a result. The spec contains: spec.members (all Editor methods/properties with name, kind, signature, description, category), spec.categories (category names), spec.types.shapes (focused shape type definitions with props), spec.types.shapeTypes (list of all shape type strings), spec.helpers (exec helper functions with descriptions, params, examples).
104
+
105
+ Examples:
106
+ - Find shape methods: return spec.members.filter(m => m.category === "shapes").map(m => ({ name: m.name, signature: m.signature }))
107
+ - Get arrow shape props: return spec.types.shapes.find(s => s.shapeType === "arrow")
108
+ - List all categories: return spec.categories
109
+ - Find a helper: return spec.helpers.find(h => h.name === "createArrowBetweenShapes")`,
110
+ inputSchema: z.object({
111
+ code: z
112
+ .string()
113
+ .describe(
114
+ 'JavaScript code that receives `spec` and returns a result. Must use `return` to produce output.'
115
+ ),
116
+ }),
117
+ annotations: {
118
+ readOnlyHint: true,
119
+ idempotentHint: true,
120
+ openWorldHint: false,
121
+ destructiveHint: false,
122
+ },
123
+ },
124
+ async ({ code }: { code: string }): Promise<CallToolResult> => {
125
+ writeToolAnalytics(opts.analytics, 'search', code)
126
+ opts.log('[tldraw-mcp] search called')
127
+
128
+ try {
129
+ specPromise ??= opts.loadSpec().then((editorApi) => ({
130
+ members: editorApi.members,
131
+ categories: editorApi.categories,
132
+ types: editorApi.types,
133
+ helperCount: editorApi.helperCount,
134
+ helpers: editorApi.helpers,
135
+ }))
136
+ const serialized = await runSearchInDynamicWorker(opts.loader, await specPromise, code)
137
+
138
+ return {
139
+ content: [{ type: 'text', text: JSON.stringify(serialized, null, 2) }],
140
+ }
141
+ } catch (err) {
142
+ const message = err instanceof Error ? err.message : String(err)
143
+ return {
144
+ content: [{ type: 'text', text: `Error: ${message}` }],
145
+ isError: true,
146
+ }
147
+ }
148
+ }
149
+ )
150
+ }
@@ -0,0 +1,29 @@
1
+ import { type App, type McpUiDisplayMode } from '@modelcontextprotocol/ext-apps/react'
2
+ import { createContext } from 'react'
3
+ import type { MCP_APP_HOST_NAMES } from '../shared/types'
4
+
5
+ export const McpAppContext = createContext<{
6
+ displayMode: McpUiDisplayMode
7
+ toggleFullscreen: (() => Promise<void>) | null
8
+ canFullscreen: boolean
9
+ canDownload: boolean
10
+ app: App | null
11
+ lastEditor: 'user' | 'ai'
12
+ hostName: MCP_APP_HOST_NAMES | null
13
+ isDev: boolean
14
+ isDevLogVisible: boolean
15
+ toggleDevLog: (() => void) | null
16
+ logIfDevMode: ((message: string) => void) | null
17
+ }>({
18
+ displayMode: 'inline',
19
+ toggleFullscreen: null,
20
+ canFullscreen: true,
21
+ canDownload: true,
22
+ app: null,
23
+ lastEditor: 'ai',
24
+ hostName: null,
25
+ isDev: false,
26
+ isDevLogVisible: false,
27
+ toggleDevLog: null,
28
+ logIfDevMode: null,
29
+ })
@@ -0,0 +1,70 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ const MAX_DEV_LOG_ENTRIES = 200
4
+ export const DEV_LOG_PANEL_HEIGHT = 140
5
+ export const DEV_LOG_PANEL_GAP = 8
6
+
7
+ export function useDevLog() {
8
+ const [isDev, setIsDev] = useState(false)
9
+ const [isDevLogVisible, setIsDevLogVisible] = useState(false)
10
+ const [devLogEntries, setDevLogEntries] = useState<string[]>([])
11
+ const isDevRef = useRef(false)
12
+
13
+ const logIfDevMode = useCallback((message: string) => {
14
+ if (!isDevRef.current) return
15
+ setDevLogEntries((entries) => {
16
+ const timestamp = new Date().toLocaleTimeString()
17
+ const nextEntries = [...entries, `[${timestamp}] ${message}`]
18
+ return nextEntries.slice(-MAX_DEV_LOG_ENTRIES)
19
+ })
20
+ }, [])
21
+
22
+ const toggleDevLog = useCallback(() => {
23
+ setIsDevLogVisible((visible) => !visible)
24
+ }, [])
25
+
26
+ const enableDevMode = useCallback(() => {
27
+ isDevRef.current = true
28
+ setIsDev(true)
29
+ setIsDevLogVisible(true)
30
+ }, [])
31
+
32
+ return {
33
+ isDev,
34
+ isDevLogVisible,
35
+ devLogEntries,
36
+ isDevRef,
37
+ logIfDevMode,
38
+ toggleDevLog,
39
+ enableDevMode,
40
+ }
41
+ }
42
+
43
+ export function DevLogPanel({
44
+ entries,
45
+ isFullscreen,
46
+ }: {
47
+ entries: string[]
48
+ isFullscreen: boolean
49
+ }) {
50
+ return (
51
+ <div
52
+ style={{
53
+ flex: isFullscreen ? '0 0 160px' : undefined,
54
+ minHeight: 80,
55
+ maxHeight: isFullscreen ? 200 : DEV_LOG_PANEL_HEIGHT,
56
+ overflow: 'auto',
57
+ padding: 12,
58
+ border: '1px solid var(--tl-color-muted-2)',
59
+ borderRadius: 8,
60
+ background: 'var(--tl-color-panel)',
61
+ fontFamily: 'monospace',
62
+ fontSize: 12,
63
+ lineHeight: 1.5,
64
+ whiteSpace: 'pre-wrap',
65
+ }}
66
+ >
67
+ {entries.length > 0 ? entries.join('\n') : 'Dev log ready.'}
68
+ </div>
69
+ )
70
+ }