Skip to content

Commit e7da725

Browse files
authored
fix(mcp-app): improve host detection, dev logging, and mobile handling (tldraw#8199)
In order to fix ChatGPT copy-paste issues and improve the MCP app's host-detection reliability, this PR refactors hostname resolution, replaces the debug module with an integrated dev log panel, and adds mobile platform handling. Reimplemented from [`max/fix-mcp-app-chatgpt-copy-paste`](https://github.com/tldraw/tldraw/tree/max/fix-mcp-app-chatgpt-copy-paste) with a clean commit history. ### Change type - [x] `improvement` ### Test plan 1. Run `yarn dev` in `apps/mcp-app` and connect from Cursor/Claude Desktop — verify the canvas loads and tools work 2. Connect from ChatGPT via `yarn dev:tunnel` — verify host detection resolves to `chatgpt` 3. Verify the dev log panel appears when `MCP_IS_DEV=true` and can be toggled via the toolbar button 4. Verify fullscreen toggle works in desktop clients and is disabled on mobile platforms - [ ] Unit tests - [ ] End to end tests ### Release notes - Improve MCP app host detection for ChatGPT clients - Add integrated dev log panel visible in dev mode - Resolve host name client-side for more reliable detection - Disable fullscreen on mobile platforms - Clean up verbose server-side logging - Update README with separate Claude Desktop local/remote setup instructions <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes how host identity is resolved, what bootstrap data is injected into the widget, and how fullscreen/display-mode is handled (including new mobile restrictions), which can impact client compatibility (ChatGPT/Claude/Cursor/VS Code). No auth or data-storage model changes beyond logging/bootstrapping. > > **Overview** > Improves MCP host detection by splitting hostname resolution into server-side (`resolveMcpAppHostNameFromServerInfo`) vs client-side (`resolveMcpAppHostNameFromClientInfo`) parsing and broadening ChatGPT detection; the widget now resolves host name from `app.getHostVersion()` instead of relying on server-injected `hostName`. > > Replaces the old ad-hoc widget `debug.ts` logging with an integrated **dev log panel** gated by a new `isDev` bootstrap flag (injected by the canvas resource) and a toolbar toggle. > > Adjusts UI behavior for mobile hosts by disabling fullscreen capability and forcing an exit from fullscreen if the host platform becomes `mobile`, and removes verbose server-side `console.error` checkpoint/debug logging. Adds an MIT `LICENSE.md`, updates the `README` setup instructions, and enables `preview_urls` in `wrangler.toml`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 450b792. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a915d60 commit e7da725

12 files changed

Lines changed: 270 additions & 153 deletions

File tree

apps/mcp-app/LICENSE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
MIT License
2+
3+
Copyright (c) 2024 tldraw Inc.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

apps/mcp-app/README.md

Lines changed: 32 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# MCP app
1+
# tldraw MCP app
22

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.
3+
This is the tldraw MCP app. It exposes an interactive tldraw canvas to AI agents via the [Model Context Protocol app specification](https://github.com/modelcontextprotocol/ext-apps/), so you can work in tldraw with agents in any MCP client that supports the MCP app spec.
44

55
## Architecture
66

@@ -19,31 +19,31 @@ Both entry points share tool registration logic in `src/register-tools.ts`.
1919

2020
### Widget
2121

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.
22+
The widget is a React app (`src/widget/mcp-app.tsx`) that renders a full tldraw canvas inside the MCP host's iframe.
2323

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.
24+
The widget handles streaming previews (shapes appear as the model streams tool arguments), and syncing state back to the server.
2525

2626
## Developing
2727

2828
Run all commands from `apps/mcp-app`.
2929

3030
### Package scripts
3131

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 |
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` | Build widget + Start a local stdio MCP server |
37+
| `yarn dev:tunnel` | Build widget + 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 |
3939

4040
`yarn dev:tunnel` requires the `cloudflared` CLI to be installed on your machine.
4141

4242
The worker defaults to production-safe behavior in `wrangler.toml`, including setting `MCP_IS_DEV="false"`. Local HTTP dev scripts override that with `MCP_IS_DEV=true` so local Claude/ChatGPT connectors suppress `ui.domain` while production deployments keep it enabled.
4343

4444
### Cursor setup
4545

46-
Add up to three servers in `~/.cursor/mcp.json`:
46+
Add these three servers in `~/.cursor/mcp.json`:
4747

4848
```json
4949
{
@@ -74,17 +74,13 @@ Add up to three servers in `~/.cursor/mcp.json`:
7474

7575
`--cwd` ensures Cursor launches in the app folder. `-s` stops yarn from writing non-JSON noise to stdout, which breaks the stdio transport.
7676

77-
### Claude Desktop setup
77+
### Claude Desktop local setup
7878

79-
Update `~/Library/Application Support/Claude/claude_desktop_config.json`:
79+
For local Claude Desktop development, use `claude_desktop_config.json` for the local HTTP and stdio servers:
8080

8181
```json
8282
{
8383
"mcpServers": {
84-
"tldraw": {
85-
"command": "npx",
86-
"args": ["-y", "mcp-remote", "https://tldraw-mcp-app.tldraw.workers.dev/mcp"]
87-
},
8884
"tldraw-local": {
8985
"command": "npx",
9086
"args": ["-y", "mcp-remote", "http://127.0.0.1:8787/mcp"]
@@ -105,41 +101,41 @@ Update `~/Library/Application Support/Claude/claude_desktop_config.json`:
105101
}
106102
```
107103

104+
### Claude Desktop remote setup
105+
106+
If you'd like to try the remote MCP server in Claude Desktop, use the in-app connector flow rather than adding the production URL to `claude_desktop_config.json`.
107+
108+
1. Open Claude Desktop
109+
2. In the sidebar, go to **Customize**
110+
3. Open **Connectors**
111+
4. Click the button to add a connector, then choose **Add custom connector**
112+
5. Give it a name such as `tldraw`
113+
6. Paste `https://tldraw-mcp-app.tldraw.workers.dev/mcp` as the server URL
114+
115+
The **Add custom connector** option is not available on the free plan, so you may need Max or another paid plan.
116+
117+
If you need Notion access in Claude Desktop, use the Notion MCP connector for that separately.
118+
108119
### ChatGPT local dev
109120

110-
ChatGPT requires an HTTPS origin, so you need a Cloudflare tunnel. You must be a workspace admin.
121+
ChatGPT requires an HTTPS origin, so you need a Cloudflare tunnel. You must be an admin of your OpenAI org/workspace to do local dev.
111122

112123
1. Run `yarn dev:tunnel` in `apps/mcp-app`
113124
2. It prints a `https://...trycloudflare.com` tunnel URL
114125
3. In ChatGPT web (not the desktop app), go to **Apps** and add your app using that tunnel URL
115-
4. You can then test in both ChatGPT web and the desktop app
126+
4. You can then test in both ChatGPT web and the desktop or mobile apps
116127

117128
`dev:tunnel` automatically wires `WORKER_ORIGIN` to the tunnel URL and sets `MCP_IS_DEV=true` for the local worker.
118129

119-
### Auth and environment flags
120-
121-
- `MCP_AUTH_TOKEN` controls bearer auth for the HTTP worker. If it is unset, the worker accepts unauthenticated local requests.
122-
- `MCP_IS_DEV` controls local-only widget behavior, such as suppressing `ui.domain` for local HTTP/tunnel connectors.
123-
124-
These flags are intentionally separate so auth configuration does not change widget-domain behavior.
125-
126130
### Iteration loop
127131

128132
1. Make code changes in `apps/mcp-app`
129133
2. Run the relevant script (`dev`, `dev:stdio`, or `dev:tunnel`)
130134
3. Disconnect and reconnect the MCP server in your client (or reload the page/app)
131-
4. When making widget changes, make sure to rebuild`yarn dev` does this automatically
135+
4. When making widget changes, make sure to rebuild, either by running `yarn build` or rerunning any of the dev scripts.
132136

133137
Reconnecting the server after changes is the most reliable way to pick up new code, especially when the widget HTML changes.
134138

135-
## License
136-
137-
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).
138-
139-
## Trademarks
140-
141-
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.
142-
143139
## Contact
144140

145141
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

apps/mcp-app/server.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
MCP_SERVER_VERSION,
1212
MCP_SERVER_WEBSITE_URL,
1313
} from './src/shared/types'
14-
import { resolveMcpAppHostName } from './src/shared/utils'
14+
import { resolveMcpAppHostNameFromServerInfo } from './src/shared/utils'
1515
import { loadCachedCanvasWidgetHtml } from './src/tools/loadCachedCanvasWidgetHtml'
1616

1717
// --- Server factory ---
@@ -31,9 +31,6 @@ export function createServer() {
3131
assets: unknown[] = [],
3232
bindings: unknown[] = []
3333
): void {
34-
console.error(
35-
`[tldraw-mcp] saveCheckpoint: id=${id}, shapes=${shapes.length}, assets=${assets.length}, bindings=${bindings.length}, existing checkpoints=${checkpoints.size}`
36-
)
3734
checkpoints.set(id, { shapes, assets, bindings })
3835
if (checkpoints.size > MAX_CHECKPOINTS) {
3936
const oldest = checkpoints.keys().next().value
@@ -46,22 +43,13 @@ export function createServer() {
4643
if (checkpoints.size > 0) {
4744
const lastKey = [...checkpoints.keys()].at(-1)!
4845
const entry = checkpoints.get(lastKey)!
49-
console.error(
50-
`[tldraw-mcp] getActiveShapes: activeCheckpointId was NULL, fell back to last checkpoint=${lastKey}, shapes=${entry.shapes.length}`
51-
)
5246
activeCheckpointId = lastKey
5347
return entry.shapes
5448
}
55-
console.error(
56-
`[tldraw-mcp] getActiveShapes: activeCheckpointId is NULL and no checkpoints, returning []`
57-
)
5849
return []
5950
}
6051
const entry = checkpoints.get(activeCheckpointId)
6152
const shapes = entry?.shapes ?? []
62-
console.error(
63-
`[tldraw-mcp] getActiveShapes: activeCheckpointId=${activeCheckpointId}, shapes=${shapes.length}, checkpoints.size=${checkpoints.size}`
64-
)
6553
return shapes
6654
}
6755

@@ -94,11 +82,8 @@ export function createServer() {
9482

9583
server.server.oninitialized = () => {
9684
const clientInfo = server.server.getClientVersion()
97-
const resolved = resolveMcpAppHostName(clientInfo?.name ?? '')
85+
const resolved = resolveMcpAppHostNameFromServerInfo(clientInfo?.name ?? '')
9886
if (resolved) clientHostName = resolved
99-
console.error(
100-
`[tldraw-mcp] Client connected: ${clientHostName ?? 'unknown'} v${clientInfo?.version ?? '?'}`
101-
)
10287
}
10388

10489
const deps: ServerDeps = {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export function registerTools(
209209
return errorResponse(
210210
'create_shapes',
211211
err,
212-
'Ensure shapesJson is a valid JSON array string of shapes objects (call read_me first for the format reference). '
212+
'Ensure shapesJson is a valid JSON array string of shapes objects (call diagram_drawing_read_me first for the format reference). '
213213
)
214214
}
215215
}
@@ -591,7 +591,7 @@ export function registerTools(
591591
const sid = deps.getSessionId()
592592
const hostName = opts.getClientHostName()
593593

594-
const bootstrap: Record<string, unknown> = { sessionId: sid, hostName }
594+
const bootstrap: Record<string, unknown> = { sessionId: sid, isDev: opts.isDev }
595595
if (activeId) {
596596
const checkpoint = deps.loadCheckpoint(activeId)
597597
if (checkpoint) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface RegisterToolsOptions {
2020
extraConnectDomains?: string[]
2121
/** Public origin of the deployed MCP worker, used for host-specific widget domains. */
2222
workerOrigin?: string
23-
/** When true, suppresses `ui.domain` on the canvas resource (required for local connectors). */
23+
/** Flag so the tools, and thus the widget, know if they are running in dev mode. */
2424
isDev: boolean
2525
/** Logging function (defaults to console.error). */
2626
log?(...args: unknown[]): void
@@ -37,7 +37,7 @@ export const MCP_SERVER_DESCRIPTION =
3737
'An interactive tldraw canvas with tools for diagramming, drawing, and more.'
3838
export const MCP_SERVER_WEBSITE_URL = 'https://www.tldraw.com'
3939
export const MCP_SERVER_INSTRUCTIONS =
40-
'Use read_me for shape format examples. For create_shapes, update_shapes, and delete_shapes, send JSON array strings (build the array first, then JSON.stringify). Use create_shapes before update_shapes or delete_shapes when the canvas is empty.'
40+
'Use diagram_drawing_read_me for shape format examples. For create_shapes, update_shapes, and delete_shapes, send JSON array strings (build the array first, then JSON.stringify). Use create_shapes before update_shapes or delete_shapes when the canvas is empty.'
4141

4242
export const CANVAS_RESOURCE_URI = 'ui://show-canvas/mcp-app.html'
4343

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,38 @@ export function generateCheckpointId(): string {
4343
return crypto.randomUUID().replace(/-/g, '').slice(0, 18)
4444
}
4545

46-
export function resolveMcpAppHostName(potentialHostName: string): MCP_APP_HOST_NAMES | undefined {
46+
// these are what we get from server.server.getClientVersion() in the worker
47+
export function resolveMcpAppHostNameFromServerInfo(
48+
potentialHostName: string
49+
): MCP_APP_HOST_NAMES | undefined {
4750
const normalizedPotentialHostName = potentialHostName.trim().toLowerCase()
51+
4852
if (normalizedPotentialHostName.includes('cursor-vscode')) return 'cursor' // we expect something like "cursor-vscode (via mcp-remote 0.1.37)"
4953
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"
54+
if (
55+
normalizedPotentialHostName.includes('openai-mcp') ||
56+
normalizedPotentialHostName.includes('chatgpt')
57+
)
58+
return 'chatgpt' // we expect something like "openai-mcp"
5159
if (normalizedPotentialHostName.includes('claude-ai')) return 'claude' // we expect something like "claude-ai (via mcp-remote 0.1.37)"
5260

5361
return undefined
5462
}
5563

64+
// these are what we expect from app.getHostVersion() (called in the client)
65+
export function resolveMcpAppHostNameFromClientInfo(
66+
potentialHostName: string
67+
): MCP_APP_HOST_NAMES | undefined {
68+
const normalizedPotentialHostName = potentialHostName.trim().toLowerCase()
69+
70+
if (normalizedPotentialHostName.includes('cursor')) return 'cursor'
71+
if (normalizedPotentialHostName.includes('visual studio code')) return 'vscode'
72+
if (normalizedPotentialHostName.includes('chatgpt')) return 'chatgpt'
73+
if (normalizedPotentialHostName.includes('claude')) return 'claude'
74+
75+
return undefined
76+
}
77+
5678
const CODE_EDITOR_HOST_NAMES: MCP_APP_HOST_NAMES[] = ['cursor', 'vscode']
5779

5880
export function isHostCodeEditor(hostName: MCP_APP_HOST_NAMES): boolean {
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { type App } from '@modelcontextprotocol/ext-apps/react'
1+
import { type App, type McpUiDisplayMode } from '@modelcontextprotocol/ext-apps/react'
22
import { createContext } from 'react'
33
import type { MCP_APP_HOST_NAMES } from '../shared/types'
44

55
export const McpAppContext = createContext<{
6-
displayMode: 'inline' | 'fullscreen'
7-
toggleFullscreen: (() => void) | null
6+
displayMode: McpUiDisplayMode
7+
toggleFullscreen: (() => Promise<void>) | null
88
canFullscreen: boolean
99
canDownload: boolean
1010
app: App | null
1111
lastEditor: 'user' | 'ai'
1212
hostName: MCP_APP_HOST_NAMES | null
13+
isDev: boolean
14+
isDevLogVisible: boolean
15+
toggleDevLog: (() => void) | null
16+
logIfDevMode: ((message: string) => void) | null
1317
}>({
1418
displayMode: 'inline',
1519
toggleFullscreen: null,
@@ -18,4 +22,8 @@ export const McpAppContext = createContext<{
1822
app: null,
1923
lastEditor: 'ai',
2024
hostName: null,
25+
isDev: false,
26+
isDevLogVisible: false,
27+
toggleDevLog: null,
28+
logIfDevMode: null,
2129
})

apps/mcp-app/src/widget/debug.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

0 commit comments

Comments
 (0)