Skip to content

Commit 11911bd

Browse files
authored
refactor(mcp-app): replace per-host domain config with isDev flag (tldraw#8184)
In order to simplify widget domain configuration and make local development reliable, this PR replaces the per-host `MCP_DOMAIN_OPENAI` / `MCP_DOMAIN_CLAUDE` environment variables with a single `MCP_IS_DEV` flag and a `getWidgetDomain()` function that resolves the correct domain per host. Reimplemented from [`max/mcp-app-approvals-fixes`](https://github.com/tldraw/tldraw/tree/max/mcp-app-approvals-fixes) with a clean commit history. ### Change type - [x] `improvement` ### Test plan 1. Run `yarn dev` in `apps/mcp-app` — verify local worker starts with `MCP_IS_DEV=true` and no `ui.domain` is set on the canvas resource 2. Run `yarn dev:tunnel` — verify tunnel mode also passes `MCP_IS_DEV=true` 3. Deploy to production — verify `MCP_IS_DEV=false` (default in wrangler.toml) causes `getWidgetDomain()` to return the correct domain for ChatGPT and Claude hosts 4. Hit `/.well-known/openai-apps-challenge` — verify it returns the verification string ### Release notes - Replace per-host domain env vars with `MCP_IS_DEV` flag for simpler widget domain configuration - Add `/.well-known/openai-apps-challenge` endpoint for OpenAI domain verification - Add `enabled` flag to Logger to suppress logs in production <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes how `ui.domain` is computed for ChatGPT/Claude and introduces a new unauthenticated verification route, which could affect production widget rendering or deployment validation if misconfigured. > > **Overview** > **Widget domain handling is refactored** to drop `MCP_DOMAIN_OPENAI`/`MCP_DOMAIN_CLAUDE` and instead use `MCP_IS_DEV` + a new `getWidgetDomain()` to set `_meta.ui.domain` only in production (ChatGPT uses `https://tldraw.com`; Claude hashes the deployed `/mcp` URL from `WORKER_ORIGIN`). > > **Dev/prod behavior is now explicit**: local `wrangler dev` and tunnel scripts pass `MCP_IS_DEV:true`, while `wrangler.toml` defaults `MCP_IS_DEV="false"`; docs are updated accordingly. The worker also adds `/.well-known/openai-apps-challenge` (no auth) and gates structured logging behind a new `Logger(enabled)` flag (enabled only in dev). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 50deff3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6846df5 commit 11911bd

9 files changed

Lines changed: 86 additions & 44 deletions

File tree

apps/mcp-app/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Run all commands from `apps/mcp-app`.
3939

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

42+
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.
43+
4244
### Cursor setup
4345

4446
Add up to three servers in `~/.cursor/mcp.json`:
@@ -112,7 +114,14 @@ ChatGPT requires an HTTPS origin, so you need a Cloudflare tunnel. You must be a
112114
3. In ChatGPT web (not the desktop app), go to **Apps** and add your app using that tunnel URL
113115
4. You can then test in both ChatGPT web and the desktop app
114116

115-
`dev:tunnel` automatically wires `WORKER_ORIGIN` to the tunnel URL.
117+
`dev:tunnel` automatically wires `WORKER_ORIGIN` to the tunnel URL and sets `MCP_IS_DEV=true` for the local worker.
118+
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.
116125

117126
### Iteration loop
118127

apps/mcp-app/dev-tunnel.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,5 @@ echo "Update claude_desktop_config.json to use:"
4747
echo " \"args\": [\"-y\", \"mcp-remote\", \"$TUNNEL_URL/mcp\"]"
4848
echo ""
4949

50-
# Start wrangler with the tunnel URL as WORKER_ORIGIN
51-
exec wrangler dev --port "$PORT" --var "WORKER_ORIGIN:$TUNNEL_URL"
50+
# Start wrangler with the tunnel URL as WORKER_ORIGIN in dev mode
51+
exec wrangler dev --port "$PORT" --var "WORKER_ORIGIN:$TUNNEL_URL" --var "MCP_IS_DEV:true"

apps/mcp-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build": "yarn build:widget",
1010
"dev": "yarn dev:http",
1111
"dev:stdio": "yarn build && tsx main.ts --stdio",
12-
"dev:http": "yarn build && wrangler dev",
12+
"dev:http": "yarn build && wrangler dev --var \"MCP_IS_DEV:true\"",
1313
"dev:tunnel": "yarn build && bash dev-tunnel.sh",
1414
"deploy": "yarn build && wrangler deploy",
1515
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",

apps/mcp-app/server.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,8 @@ export function createServer() {
119119
loadWidgetHtml: loadCachedCanvasWidgetHtml,
120120
}
121121

122-
const httpDomain = {
123-
openai: process.env.MCP_DOMAIN_OPENAI ?? '',
124-
claude: process.env.MCP_DOMAIN_CLAUDE ?? '',
125-
}
126-
127122
registerTools(server, deps, {
128-
httpDomain: httpDomain.openai || httpDomain.claude ? httpDomain : undefined,
123+
isDev: true,
129124
log: console.error,
130125
getClientHostName: () => clientHostName,
131126
})

apps/mcp-app/src/logger.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,34 @@
66
*/
77

88
export class Logger {
9-
constructor(private prefix: string) {}
9+
constructor(
10+
private prefix: string,
11+
private enabled: boolean
12+
) {}
1013

1114
info(message: string, data?: Record<string, unknown>) {
15+
if (!this.enabled) return
1216
console.error(
1317
JSON.stringify({ level: 'info', prefix: this.prefix, message, ...data, ts: Date.now() })
1418
)
1519
}
1620

1721
error(message: string, data?: Record<string, unknown>) {
22+
if (!this.enabled) return
1823
console.error(
1924
JSON.stringify({ level: 'error', prefix: this.prefix, message, ...data, ts: Date.now() })
2025
)
2126
}
2227

2328
debug(message: string, data?: Record<string, unknown>) {
29+
if (!this.enabled) return
2430
console.error(
2531
JSON.stringify({ level: 'debug', prefix: this.prefix, message, ...data, ts: Date.now() })
2632
)
2733
}
2834

2935
child(prefix: string): Logger {
30-
return new Logger(`${this.prefix}:${prefix}`)
36+
return new Logger(`${this.prefix}:${prefix}`, this.enabled)
3137
}
3238

3339
/** Returns a log function compatible with RegisterToolsOptions.log */

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

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,51 @@ function injectBootstrapData(html: string, bootstrap: Record<string, unknown>):
6161
return html.slice(0, lastIdx) + bootstrapScript + html.slice(lastIdx)
6262
}
6363

64+
/**
65+
* Returns the widget domain for the given host, or `undefined` in dev mode.
66+
*
67+
* - ChatGPT: https://developers.openai.com/apps-sdk/build/mcp-server#widget-domains
68+
* "Set `_meta.ui.domain` on the widget resource template. This is required for app
69+
* submission and must be unique per app. ChatGPT renders the widget under
70+
* `<domain>.web-sandbox.oaiusercontent.com`"
71+
*
72+
* - Claude: https://claude.com/docs/connectors/building/mcp-apps/cross-compatibility#domain-handling
73+
* "Compute the value by running:
74+
* `node -e 'const u = "https://example.com/mcp"; console.log(require("crypto").createHash("sha256").update(u).digest("hex"))'`"
75+
*/
76+
async function getWidgetDomain(
77+
hostName: string | undefined,
78+
isDev: boolean,
79+
workerOrigin?: string
80+
): Promise<string | undefined> {
81+
if (isDev) return undefined
82+
if (hostName === 'chatgpt') return 'https://tldraw.com'
83+
if (hostName === 'claude' && workerOrigin) {
84+
const mcpUrl = new URL('/mcp', workerOrigin).toString()
85+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(mcpUrl))
86+
const hash = Array.from(new Uint8Array(digest), (byte) =>
87+
byte.toString(16).padStart(2, '0')
88+
).join('')
89+
return `${hash}.claudemcpcontent.com`
90+
}
91+
return undefined
92+
}
93+
6494
// --- Registration ---
6595

6696
export function registerTools(
6797
server: McpServer,
6898
deps: ServerDeps,
69-
opts?: RegisterToolsOptions
99+
opts: RegisterToolsOptions
70100
): void {
71-
const log = opts?.log ?? ((...args: unknown[]) => console.error(...args))
101+
const log = opts.log ?? ((...args: unknown[]) => console.error(...args))
72102
const getBindingFromId = (binding: unknown): TLShape['id'] | null => {
73103
if (!binding || typeof binding !== 'object') return null
74104
const maybeFromId = (binding as { fromId?: unknown }).fromId
75105
return typeof maybeFromId === 'string' ? (maybeFromId as TLShape['id']) : null
76106
}
77107

78-
const analytics = opts?.analytics
108+
const analytics = opts.analytics
79109

80110
// --- read_me ---
81111

@@ -592,7 +622,8 @@ export function registerTools(
592622
// has shapes synchronously on mount — before any streaming begins.
593623
const activeId = deps.getActiveCheckpointId()
594624
const sid = deps.getSessionId()
595-
const hostName = opts?.getClientHostName()
625+
const hostName = opts.getClientHostName()
626+
596627
const bootstrap: Record<string, unknown> = { sessionId: sid, hostName }
597628
if (activeId) {
598629
const checkpoint = deps.loadCheckpoint(activeId)
@@ -605,16 +636,9 @@ export function registerTools(
605636
}
606637
html = injectBootstrapData(html, bootstrap)
607638

608-
// Resolve domain from client identity (only when serving over HTTP with configured domains)
609-
let domain: string | undefined
610-
if (opts?.httpDomain?.openai || opts?.httpDomain?.claude) {
611-
if (hostName === 'chatgpt') {
612-
domain = opts.httpDomain.openai
613-
} else if (hostName === 'claude') {
614-
domain = opts.httpDomain.claude
615-
}
616-
log(`[tldraw-mcp] Serving resource to "${hostName}" with domain: ${domain}`)
617-
}
639+
const domain = await getWidgetDomain(hostName, opts.isDev, opts.workerOrigin)
640+
641+
log(`[tldraw-mcp] Serving resource to "${hostName}" with domain: ${domain}`)
618642

619643
return {
620644
contents: [
@@ -629,10 +653,10 @@ export function registerTools(
629653
'https://cdn.tldraw.com',
630654
'https://fonts.googleapis.com',
631655
'https://fonts.gstatic.com',
632-
...(opts?.extraResourceDomains ?? []),
656+
...(opts.extraResourceDomains ?? []),
633657
'blob:',
634658
],
635-
connectDomains: ['https://cdn.tldraw.com', ...(opts?.extraConnectDomains ?? [])],
659+
connectDomains: ['https://cdn.tldraw.com', ...(opts.extraConnectDomains ?? [])],
636660
},
637661
permissions: { clipboardWrite: {} },
638662
...(domain ? { domain } : {}),

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ export interface RegisterToolsOptions {
1818
extraResourceDomains?: string[]
1919
/** Extra CSP connect domains. */
2020
extraConnectDomains?: string[]
21-
/** When set, the canvas resource domain is resolved from the connecting client. */
22-
httpDomain?: { openai: string; claude: string }
21+
/** Public origin of the deployed MCP worker, used for host-specific widget domains. */
22+
workerOrigin?: string
23+
/** When true, suppresses `ui.domain` on the canvas resource (required for local connectors). */
24+
isDev: boolean
2325
/** Logging function (defaults to console.error). */
2426
log?(...args: unknown[]): void
2527
/** Analytics engine dataset. */

apps/mcp-app/src/worker.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,8 @@ interface Env {
3131
ASSETS: Fetcher
3232
RATE_LIMITER: RateLimit
3333
MCP_AUTH_TOKEN: string
34+
MCP_IS_DEV: string
3435
WORKER_ORIGIN: string
35-
MCP_DOMAIN_OPENAI: string
36-
MCP_DOMAIN_CLAUDE: string
3736
MCP_ANALYTICS?: AnalyticsEngineDataset
3837
}
3938

@@ -76,14 +75,14 @@ export class TldrawMCP extends McpAgent<Env> {
7675
instructions: MCP_SERVER_INSTRUCTIONS,
7776
}
7877
)
78+
isDev = this.env.MCP_IS_DEV === 'true'
79+
logsEnabled = this.isDev
7980
activeCheckpointId: string | null = null
8081
sessionId: string = ''
81-
logger = new Logger('TldrawMCP')
82+
logger = new Logger('TldrawMCP', this.logsEnabled)
8283
clientHostName: MCP_APP_HOST_NAMES | undefined = undefined
8384

8485
async init() {
85-
this.logger.info('Initializing Durable Object')
86-
8786
this.server.server.oninitialized = () => {
8887
const clientInfo = this.server.server.getClientVersion()
8988
const resolved = resolveMcpAppHostName(clientInfo?.name ?? '')
@@ -150,21 +149,17 @@ export class TldrawMCP extends McpAgent<Env> {
150149
loadWidgetHtml: async () => widgetHtml,
151150
}
152151

153-
const workerOrigin = this.env.WORKER_ORIGIN || ''
154-
const domainOpenai = this.env.MCP_DOMAIN_OPENAI || ''
155-
const domainClaude = this.env.MCP_DOMAIN_CLAUDE || ''
152+
const workerOrigin = this.env.WORKER_ORIGIN
156153

157154
registerTools(this.server, deps, {
158155
log: this.logger.toLogFn(),
159156
extraResourceDomains: workerOrigin ? [workerOrigin] : [],
160157
extraConnectDomains: workerOrigin ? [workerOrigin] : [],
161-
httpDomain:
162-
domainOpenai || domainClaude ? { openai: domainOpenai, claude: domainClaude } : undefined,
158+
workerOrigin,
159+
isDev: this.isDev,
163160
analytics: this.env.MCP_ANALYTICS,
164161
getClientHostName: () => this.clientHostName,
165162
})
166-
167-
this.logger.info('Initialization complete')
168163
}
169164

170165
// --- Checkpoint helpers ---
@@ -225,6 +220,7 @@ const sseHandler = TldrawMCP.serveSSE('/sse')
225220
export default {
226221
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
227222
try {
223+
const requireAuth = Boolean(env.MCP_AUTH_TOKEN)
228224
const url = new URL(request.url)
229225

230226
// CORS preflight
@@ -237,8 +233,15 @@ export default {
237233
return Response.json({ status: 'ok', timestamp: Date.now() })
238234
}
239235

240-
// Auth check for MCP endpoints: skip if MCP_AUTH_TOKEN not set (local dev)
241-
if (env.MCP_AUTH_TOKEN) {
236+
// Domain verification (no auth)
237+
if (url.pathname === '/.well-known/openai-apps-challenge') {
238+
return new Response('kd9yRY8fxUTGRLJ6d22gpfATKZhXhHAu5Vdn6HWJsIQ', {
239+
headers: { 'Content-Type': 'text/plain' },
240+
})
241+
}
242+
243+
// Require bearer auth only when an auth token is configured.
244+
if (requireAuth) {
242245
const auth = request.headers.get('Authorization')
243246
if (auth !== `Bearer ${env.MCP_AUTH_TOKEN}`) {
244247
return corsResponse(new Response('Unauthorized', { status: 401 }))

apps/mcp-app/wrangler.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ new_sqlite_classes = ["TldrawMCP"]
1919
# Set this to the public URL of the worker. Used for constructing absolute image URLs.
2020
# Override with `--var WORKER_ORIGIN:https://your-tunnel.trycloudflare.com` for cloudflared tunnels.
2121
WORKER_ORIGIN = "https://tldraw-mcp-app.tldraw.workers.dev"
22+
# Default to production-safe widget behavior. Local dev scripts override this with
23+
# `--var MCP_IS_DEV:true` so local HTTP connectors omit `ui.domain`.
24+
MCP_IS_DEV = "false"
2225

2326
[[analytics_engine_datasets]]
2427
binding = "MCP_ANALYTICS"

0 commit comments

Comments
 (0)