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,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
|
+
}
|