Skip to content

Commit 0d748e7

Browse files
authored
fix(mcp-app): host name resolution, tool annotations, and widget improvements (tldraw#8168)
In order to improve MCP client identity tracking and fix several widget UX issues, this PR refactors the MCP app with a clean commit history. Clean reimplementation of [`max/mcp-fixes`](https://github.com/tldraw/tldraw/tree/max/mcp-fixes). **Key changes:** - **Rename `cloudflare-worker.ts` → `worker.ts`** for clarity - **Fix build scripts** so dev:stdio, dev:tunnel, and deploy always build the widget first - **Centralize server metadata** — use shared constants (name, version, title, etc.) across server and widget; bump version to 0.1.0; extract `injectBootstrapData()` helper - **Add client host name resolution** — resolve connecting client identity (cursor, vscode, claude, chatgpt) from MCP client version string; thread through bootstrap data; use for domain resolution instead of raw client strings; persist in worker SQLite - **Extract image guard and app context** into separate modules; rename context to `McpAppContext` - **Improve widget UI** — show "Build it" only in code editors; reorder share panel buttons; use download icon; simplify state; use typed host context; query host capabilities for fullscreen/download - **Add explicit tool annotations** — `readOnlyHint`/`destructiveHint` on all tools so MCP clients can make better auto-approval decisions - **Add README** with architecture overview, setup instructions, and dev workflow ### Change type - [x] `improvement` ### Test plan 1. Connect via Cursor — verify "Build it" button appears, download and fullscreen buttons work 2. Connect via Claude Desktop — verify "Build it" is hidden, download and fullscreen work 3. Connect via ChatGPT — verify resource domain resolves correctly 4. Run `yarn dev:stdio` — verify widget builds before server starts - [ ] Unit tests - [ ] End to end tests <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes MCP server metadata/bootstrap injection and client-based domain resolution across both Node and Worker entry points, which could affect connectivity or resource loading in different hosts. > > **Overview** > **Client/host identity is now normalized and propagated end-to-end.** The server/worker resolve a canonical host name (`cursor`/`vscode`/`claude`/`chatgpt`) from the MCP client string, persist it in the Durable Object, and embed it into the widget bootstrap so the UI and HTTP `domain` selection use the same identity. > > **Server/tool registration is tightened and standardized.** Server metadata is centralized via shared constants (including a version bump to `0.1.0`), bootstrap HTML injection is extracted to a helper, and all tools now include explicit `readOnlyHint`/`destructiveHint` annotations (plus annotations for the app-only `event` tool). > > **Widget UX and host integration are refined.** Host context handling is typed/safer, fullscreen/download buttons are gated by host capabilities, the Share panel is reordered with an icon download button, and the "Build it" action is shown only for code-editor hosts; image/asset/embed inputs are proactively blocked via extracted `image-guard` overrides. > > **Dev/deploy ergonomics and docs.** `dev:stdio`, `dev:http`, `dev:tunnel`, and `deploy` now always build the widget first, the Cloudflare entry point is set to `src/worker.ts`, and a new `README.md` documents architecture and setup. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5d15524. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5d2e4fc commit 0d748e7

12 files changed

Lines changed: 479 additions & 237 deletions

File tree

apps/mcp-app/README.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# MCP app
2+
3+
This is the tldraw MCP app. It exposes an interactive tldraw canvas to AI agents via the [Model Context Protocol](https://modelcontextprotocol.io/), so agents in Cursor, Claude Desktop, ChatGPT, and VS Code can draw diagrams and shapes on a canvas during a conversation.
4+
5+
## Architecture
6+
7+
The app has two parts: a **server** and a **widget**.
8+
9+
### Server
10+
11+
The server registers MCP tools (`create_shapes`, `update_shapes`, `delete_shapes`, `diagram_drawing_read_me`) and serves the widget HTML as an MCP App resource.
12+
13+
There are two entry points:
14+
15+
- `main.ts` — Node.js stdio transport, for local clients like Claude Desktop and Cursor
16+
- `src/worker.ts` — Cloudflare Workers with a Durable Object (`TldrawMCP`) backed by SQLite for persistent checkpoint storage
17+
18+
Both entry points share tool registration logic in `src/register-tools.ts`.
19+
20+
### Widget
21+
22+
The widget is a React app (`src/widget/mcp-app.tsx`) that renders a full tldraw canvas inside the MCP host's iframe. Vite bundles it into a single HTML file (`dist/mcp-app.html`) using `vite-plugin-singlefile`, which the server injects bootstrap data into before serving.
23+
24+
The widget handles streaming previews (shapes appear as the model streams tool arguments), checkpoint persistence to `localStorage`, and syncing state back to the server.
25+
26+
## Developing
27+
28+
Run all commands from `apps/mcp-app`.
29+
30+
### Package scripts
31+
32+
| Command | What it does |
33+
| ----------------- | ----------------------------------------------------------------------------------- |
34+
| `yarn build` | Build the widget HTML |
35+
| `yarn dev` | Build widget + start local Cloudflare worker (HTTP MCP on `localhost:8787`) |
36+
| `yarn dev:stdio` | Start a local stdio MCP server |
37+
| `yarn dev:tunnel` | Start a Cloudflare tunnel + local worker with `WORKER_ORIGIN` set to the tunnel URL |
38+
| `yarn deploy` | Build widget + deploy the Cloudflare worker to production |
39+
40+
`yarn dev:tunnel` requires the `cloudflared` CLI to be installed on your machine.
41+
42+
### Cursor setup
43+
44+
Add up to three servers in `~/.cursor/mcp.json`:
45+
46+
```json
47+
{
48+
"mcpServers": {
49+
"tldraw": {
50+
"transport": "http",
51+
"url": "https://tldraw-mcp-app.tldraw.workers.dev/mcp"
52+
},
53+
"tldraw-local": {
54+
"command": "npx",
55+
"args": ["-y", "mcp-remote", "http://127.0.0.1:8787/mcp"]
56+
},
57+
"tldraw-local-stdio": {
58+
"command": "yarn",
59+
"args": [
60+
"--cwd",
61+
"<path-to-tldraw-repo>/tldraw/apps/mcp-app",
62+
"run",
63+
"-s",
64+
"tsx",
65+
"main.ts",
66+
"--stdio"
67+
]
68+
}
69+
}
70+
}
71+
```
72+
73+
`--cwd` ensures Cursor launches in the app folder. `-s` stops yarn from writing non-JSON noise to stdout, which breaks the stdio transport.
74+
75+
### Claude Desktop setup
76+
77+
Update `~/Library/Application Support/Claude/claude_desktop_config.json`:
78+
79+
```json
80+
{
81+
"mcpServers": {
82+
"tldraw": {
83+
"command": "npx",
84+
"args": ["-y", "mcp-remote", "https://tldraw-mcp-app.tldraw.workers.dev/mcp"]
85+
},
86+
"tldraw-local": {
87+
"command": "npx",
88+
"args": ["-y", "mcp-remote", "http://127.0.0.1:8787/mcp"]
89+
},
90+
"tldraw-local-stdio": {
91+
"command": "yarn",
92+
"args": [
93+
"--cwd",
94+
"<path-to-tldraw-repo>/tldraw/apps/mcp-app",
95+
"run",
96+
"-s",
97+
"tsx",
98+
"main.ts",
99+
"--stdio"
100+
]
101+
}
102+
}
103+
}
104+
```
105+
106+
### ChatGPT local dev
107+
108+
ChatGPT requires an HTTPS origin, so you need a Cloudflare tunnel. You must be a workspace admin.
109+
110+
1. Run `yarn dev:tunnel` in `apps/mcp-app`
111+
2. It prints a `https://...trycloudflare.com` tunnel URL
112+
3. In ChatGPT web (not the desktop app), go to **Apps** and add your app using that tunnel URL
113+
4. You can then test in both ChatGPT web and the desktop app
114+
115+
`dev:tunnel` automatically wires `WORKER_ORIGIN` to the tunnel URL.
116+
117+
### Iteration loop
118+
119+
1. Make code changes in `apps/mcp-app`
120+
2. Run the relevant script (`dev`, `dev:stdio`, or `dev:tunnel`)
121+
3. Disconnect and reconnect the MCP server in your client (or reload the page/app)
122+
4. When making widget changes, make sure to rebuild — `yarn dev` does this automatically
123+
124+
Reconnecting the server after changes is the most reliable way to pick up new code, especially when the widget HTML changes.
125+
126+
## License
127+
128+
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
129+
130+
## Trademarks
131+
132+
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
133+
134+
## Contact
135+
136+
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
137+
138+
## Community
139+
140+
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).

apps/mcp-app/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
"build:widget": "node ../../packages/tldraw/scripts/copy-css-files.mjs && vite build && mv dist/index.html dist/mcp-app.html",
99
"build": "yarn build:widget",
1010
"dev": "yarn dev:http",
11-
"dev:stdio": "tsx main.ts --stdio",
12-
"dev:http": "yarn build:widget && wrangler dev",
13-
"dev:tunnel": "bash dev-tunnel.sh",
14-
"deploy": "yarn build:widget && wrangler deploy",
11+
"dev:stdio": "yarn build && tsx main.ts --stdio",
12+
"dev:http": "yarn build && wrangler dev",
13+
"dev:tunnel": "yarn build && bash dev-tunnel.sh",
14+
"deploy": "yarn build && wrangler deploy",
1515
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
1616
"test": "yarn run -T vitest --passWithNoTests",
1717
"test-ci": "yarn run -T vitest run --passWithNoTests",

apps/mcp-app/server.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
22
import type { TLShape } from 'tldraw'
33
import { registerTools } from './src/register-tools'
4-
import type { ServerDeps } from './src/shared/types'
5-
import { MAX_CHECKPOINTS } from './src/shared/types'
4+
import type { MCP_APP_HOST_NAMES, ServerDeps } from './src/shared/types'
5+
import {
6+
MAX_CHECKPOINTS,
7+
MCP_SERVER_DESCRIPTION,
8+
MCP_SERVER_INSTRUCTIONS,
9+
MCP_SERVER_NAME,
10+
MCP_SERVER_TITLE,
11+
MCP_SERVER_VERSION,
12+
MCP_SERVER_WEBSITE_URL,
13+
} from './src/shared/types'
14+
import { resolveMcpAppHostName } from './src/shared/utils'
615
import { loadCachedCanvasWidgetHtml } from './src/tools/loadCachedCanvasWidgetHtml'
716

817
// --- Server factory ---
@@ -68,15 +77,27 @@ export function createServer() {
6877
return entry?.bindings ?? []
6978
}
7079

71-
const server = new McpServer({
72-
name: 'tldraw',
73-
version: '1.0.0',
74-
})
80+
let clientHostName: MCP_APP_HOST_NAMES | undefined
81+
82+
const server = new McpServer(
83+
{
84+
name: MCP_SERVER_NAME,
85+
version: MCP_SERVER_VERSION,
86+
title: MCP_SERVER_TITLE,
87+
description: MCP_SERVER_DESCRIPTION,
88+
websiteUrl: MCP_SERVER_WEBSITE_URL,
89+
},
90+
{
91+
instructions: MCP_SERVER_INSTRUCTIONS,
92+
}
93+
)
7594

7695
server.server.oninitialized = () => {
7796
const clientInfo = server.server.getClientVersion()
97+
const resolved = resolveMcpAppHostName(clientInfo?.name ?? '')
98+
if (resolved) clientHostName = resolved
7899
console.error(
79-
`[tldraw-mcp] Client connected: ${clientInfo?.name ?? 'unknown'} v${clientInfo?.version ?? '?'}`
100+
`[tldraw-mcp] Client connected: ${clientHostName ?? 'unknown'} v${clientInfo?.version ?? '?'}`
80101
)
81102
}
82103

@@ -106,6 +127,7 @@ export function createServer() {
106127
registerTools(server, deps, {
107128
httpDomain: httpDomain.openai || httpDomain.claude ? httpDomain : undefined,
108129
log: console.error,
130+
getClientHostName: () => clientHostName,
109131
})
110132

111133
return server

apps/mcp-app/src/register-tools.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,23 @@ import { READ_ME_CONTENT } from './tools/read-me'
4444
/**
4545
* Shared tool/resource registration logic for both Node.js and Cloudflare Workers entry points.
4646
*
47-
* Both `server.ts` (Node) and `src/cloudflare-worker.ts` (Workers) call `registerTools()`
47+
* Both `server.ts` (Node) and `src/worker.ts` (Workers) call `registerTools()`
4848
* with platform-specific storage backends.
4949
*/
5050

51+
// --- Helpers ---
52+
53+
function injectBootstrapData(html: string, bootstrap: Record<string, unknown>): string {
54+
const toBase64 =
55+
typeof Buffer !== 'undefined' ? (s: string) => Buffer.from(s).toString('base64') : btoa
56+
const encoded = toBase64(JSON.stringify(bootstrap))
57+
const bootstrapScript = `<script>window.__TLDRAW_BOOTSTRAP__=JSON.parse(atob("${encoded}"))</script>`
58+
// Replace the LAST </head> — the inlined JS bundle may contain </head> as a string literal
59+
const lastIdx = html.lastIndexOf('</head>')
60+
if (lastIdx === -1) return html
61+
return html.slice(0, lastIdx) + bootstrapScript + html.slice(lastIdx)
62+
}
63+
5164
// --- Registration ---
5265

5366
export function registerTools(
@@ -75,6 +88,7 @@ export function registerTools(
7588
readOnlyHint: true,
7689
idempotentHint: true,
7790
openWorldHint: false,
91+
destructiveHint: false,
7892
},
7993
},
8094
async (): Promise<CallToolResult> => {
@@ -97,7 +111,8 @@ export function registerTools(
97111
description: 'Creates shapes, drawings, and diagrams on the tldraw canvas.',
98112
inputSchema: createShapesInputSchema,
99113
annotations: {
100-
destructiveHint: true,
114+
readOnlyHint: false,
115+
destructiveHint: false,
101116
idempotentHint: false,
102117
openWorldHint: false,
103118
},
@@ -179,6 +194,7 @@ export function registerTools(
179194
description: 'Updates existing shapes, diagrams, and drawings on the tldraw canvas.',
180195
inputSchema: updateShapesInputSchema,
181196
annotations: {
197+
readOnlyHint: false,
182198
destructiveHint: true,
183199
idempotentHint: false,
184200
openWorldHint: false,
@@ -301,6 +317,7 @@ export function registerTools(
301317
description: 'Deletes shapes by id from a JSON string (string[]).',
302318
inputSchema: deleteShapesInputSchema,
303319
annotations: {
320+
readOnlyHint: false,
304321
destructiveHint: true,
305322
idempotentHint: false,
306323
openWorldHint: false,
@@ -381,6 +398,7 @@ export function registerTools(
381398
inputSchema: z.object({ checkpointId: z.string().min(1) }),
382399
annotations: {
383400
readOnlyHint: true,
401+
destructiveHint: false,
384402
idempotentHint: true,
385403
openWorldHint: false,
386404
},
@@ -471,7 +489,8 @@ export function registerTools(
471489
bindingsJson: z.string().optional(),
472490
}),
473491
annotations: {
474-
destructiveHint: true,
492+
readOnlyHint: false,
493+
destructiveHint: false,
475494
idempotentHint: false,
476495
openWorldHint: false,
477496
},
@@ -527,6 +546,12 @@ export function registerTools(
527546
event: z.string().min(1),
528547
value: z.number().optional(),
529548
}),
549+
annotations: {
550+
readOnlyHint: false,
551+
destructiveHint: false,
552+
idempotentHint: false,
553+
openWorldHint: true,
554+
},
530555
_meta: { ui: { visibility: ['app'] } },
531556
},
532557
async ({ event, value }: { event: string; value?: number }): Promise<CallToolResult> => {
@@ -567,7 +592,8 @@ export function registerTools(
567592
// has shapes synchronously on mount — before any streaming begins.
568593
const activeId = deps.getActiveCheckpointId()
569594
const sid = deps.getSessionId()
570-
const bootstrap: Record<string, unknown> = { sessionId: sid }
595+
const hostName = opts?.getClientHostName()
596+
const bootstrap: Record<string, unknown> = { sessionId: sid, hostName }
571597
if (activeId) {
572598
const checkpoint = deps.loadCheckpoint(activeId)
573599
if (checkpoint) {
@@ -577,30 +603,17 @@ export function registerTools(
577603
bootstrap.bindings = checkpoint.bindings
578604
}
579605
}
580-
const toBase64 =
581-
typeof Buffer !== 'undefined' ? (s: string) => Buffer.from(s).toString('base64') : btoa
582-
const encoded = toBase64(JSON.stringify(bootstrap))
583-
const bootstrapScript = `<script>window.__TLDRAW_BOOTSTRAP__=JSON.parse(atob("${encoded}"))</script>`
584-
// Replace the LAST </head> — the inlined JS bundle may contain </head> as a string literal
585-
const lastIdx = html.lastIndexOf('</head>')
586-
if (lastIdx !== -1) {
587-
html = html.slice(0, lastIdx) + bootstrapScript + html.slice(lastIdx)
588-
}
606+
html = injectBootstrapData(html, bootstrap)
589607

590608
// Resolve domain from client identity (only when serving over HTTP with configured domains)
591609
let domain: string | undefined
592610
if (opts?.httpDomain?.openai || opts?.httpDomain?.claude) {
593-
const clientName = server.server.getClientVersion()?.name ?? ''
594-
if (clientName === 'openai-mcp') {
611+
if (hostName === 'chatgpt') {
595612
domain = opts.httpDomain.openai
596-
} else if (
597-
clientName === 'claude-ai' ||
598-
clientName === 'Anthropic' ||
599-
clientName === 'Anthropic/ClaudeAI'
600-
) {
613+
} else if (hostName === 'claude') {
601614
domain = opts.httpDomain.claude
602615
}
603-
log(`[tldraw-mcp] Serving resource to "${clientName}" with domain: ${domain}`)
616+
log(`[tldraw-mcp] Serving resource to "${hostName}" with domain: ${domain}`)
604617
}
605618

606619
return {

apps/mcp-app/src/shared/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ export interface RegisterToolsOptions {
2424
log?(...args: unknown[]): void
2525
/** Analytics engine dataset. */
2626
analytics?: AnalyticsEngineDataset
27+
/** Returns the resolved host name of the connected client. */
28+
getClientHostName(): MCP_APP_HOST_NAMES | undefined
2729
}
2830

2931
export const MCP_SERVER_NAME = 'tldraw'
30-
export const MCP_SERVER_VERSION = '1.0.0'
32+
export const MCP_SERVER_VERSION = '0.1.0'
3133
export const MCP_SERVER_TITLE = 'tldraw Canvas'
3234
export const MCP_SERVER_DESCRIPTION =
3335
'An interactive tldraw canvas with tools for diagramming, drawing, and more.'
@@ -55,3 +57,5 @@ export const ALLOWED_IMAGE_TYPES: Record<string, string> = Object.fromEntries(
5557
)
5658

5759
export const MAX_CHECKPOINTS = 200
60+
61+
export type MCP_APP_HOST_NAMES = 'cursor' | 'vscode' | 'claude' | 'chatgpt'

apps/mcp-app/src/shared/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
22
import type { TLShape } from 'tldraw'
3+
import type { MCP_APP_HOST_NAMES } from './types'
34

45
export function isPlainObject(value: unknown): value is Record<string, unknown> {
56
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -41,3 +42,19 @@ export function errorResponse(toolName: string, err: unknown, hint?: string): Ca
4142
export function generateCheckpointId(): string {
4243
return crypto.randomUUID().replace(/-/g, '').slice(0, 18)
4344
}
45+
46+
export function resolveMcpAppHostName(potentialHostName: string): MCP_APP_HOST_NAMES | undefined {
47+
const normalizedPotentialHostName = potentialHostName.trim().toLowerCase()
48+
if (normalizedPotentialHostName.includes('cursor-vscode')) return 'cursor' // we expect something like "cursor-vscode (via mcp-remote 0.1.37)"
49+
if (normalizedPotentialHostName.includes('visual studio code')) return 'vscode' // we expect something like "Visual Studio Code (via mcp-remote 0.1.37)"
50+
if (normalizedPotentialHostName.includes('openai-mcp')) return 'chatgpt' // we expect something like "openai-mcp"
51+
if (normalizedPotentialHostName.includes('claude-ai')) return 'claude' // we expect something like "claude-ai (via mcp-remote 0.1.37)"
52+
53+
return undefined
54+
}
55+
56+
const CODE_EDITOR_HOST_NAMES: MCP_APP_HOST_NAMES[] = ['cursor', 'vscode']
57+
58+
export function isHostCodeEditor(hostName: MCP_APP_HOST_NAMES): boolean {
59+
return CODE_EDITOR_HOST_NAMES.includes(hostName)
60+
}

0 commit comments

Comments
 (0)