snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
431
apps/mcp-app/.claude/skills/add-app-to-server/SKILL.md
Normal file
431
apps/mcp-app/.claude/skills/add-app-to-server/SKILL.md
Normal file
@@ -0,0 +1,431 @@
|
||||
---
|
||||
name: add-app-to-server
|
||||
description: This skill should be used when the user asks to "add an app to my MCP server", "add UI to my MCP server", "add a view to my MCP tool", "enrich MCP tools with UI", "add interactive UI to existing server", "add MCP Apps to my server", or needs to add interactive UI capabilities to an existing MCP server that already has tools. Provides guidance for analyzing existing tools and adding MCP Apps UI resources.
|
||||
---
|
||||
|
||||
# Add UI to MCP Server
|
||||
|
||||
Enrich an existing MCP server's tools with interactive UIs using the MCP Apps SDK (`@modelcontextprotocol/ext-apps`).
|
||||
|
||||
## How It Works
|
||||
|
||||
Existing tools get paired with HTML resources that render inline in the host's conversation. The tool continues to work for text-only clients — UI is an enhancement, not a replacement. Each tool that benefits from UI gets linked to a resource via `_meta.ui.resourceUri`, and the host renders that resource in a sandboxed iframe when the tool is called.
|
||||
|
||||
## Getting Reference Code
|
||||
|
||||
Clone the SDK repository for working examples and API documentation:
|
||||
|
||||
```bash
|
||||
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
|
||||
```
|
||||
|
||||
### API Reference (Source Files)
|
||||
|
||||
Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`:
|
||||
|
||||
| File | Contents |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle |
|
||||
| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, `getUiCapability`, tool visibility options |
|
||||
| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes |
|
||||
| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` |
|
||||
| `src/react/useApp.tsx` | `useApp` hook for React apps |
|
||||
| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks |
|
||||
|
||||
### Key Examples (Mixed Tool Patterns)
|
||||
|
||||
These examples demonstrate servers with both App-enhanced and plain tools — the exact pattern you're adding:
|
||||
|
||||
| Example | Pattern |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `examples/map-server/` | `show-map` (App tool) + `geocode` (plain tool) |
|
||||
| `examples/pdf-server/` | `display_pdf` (App tool) + `list_pdfs` (plain tool) + `read_pdf_bytes` (app-only tool) |
|
||||
| `examples/system-monitor-server/` | `get-system-info` (App tool) + `poll-system-stats` (app-only polling tool) |
|
||||
|
||||
### Framework Templates
|
||||
|
||||
Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`:
|
||||
|
||||
| Template | Key Files |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` |
|
||||
| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) |
|
||||
| `basic-server-vue/` | `server.ts`, `src/App.vue` |
|
||||
| `basic-server-svelte/` | `server.ts`, `src/App.svelte` |
|
||||
| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
|
||||
## Step 1: Analyze Existing Tools
|
||||
|
||||
Before writing any code, analyze the server's existing tools and determine which ones benefit from UI.
|
||||
|
||||
1. Read the server source and list all registered tools
|
||||
2. For each tool, assess whether it would benefit from UI (returns data that could be visualized, involves user interaction, etc.) vs. is fine as text-only (simple lookups, utility functions)
|
||||
3. Identify tools that could become **app-only helpers** (data the UI needs to poll/fetch but the model doesn't need to call directly)
|
||||
4. Present the analysis to the user and confirm which tools to enhance
|
||||
|
||||
### Decision Framework
|
||||
|
||||
| Tool output type | UI benefit | Example |
|
||||
| -------------------------------- | ------------------------------------------- | --------------------------------- |
|
||||
| Structured data / lists / tables | High — interactive table, search, filtering | List of items, search results |
|
||||
| Metrics / numbers over time | High — charts, gauges, dashboards | System stats, analytics |
|
||||
| Media / rich content | High — viewer, player, renderer | Maps, PDFs, images, video |
|
||||
| Simple text / confirmations | Low — text is fine | "File created", "Setting updated" |
|
||||
| Data for other tools | Consider app-only | Polling endpoints, chunk loaders |
|
||||
|
||||
## Step 2: Add Dependencies
|
||||
|
||||
```bash
|
||||
npm install @modelcontextprotocol/ext-apps
|
||||
npm install -D vite vite-plugin-singlefile
|
||||
```
|
||||
|
||||
Plus framework-specific dependencies if needed (e.g., `react`, `react-dom`, `@vitejs/plugin-react` for React).
|
||||
|
||||
Use `npm install` to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
|
||||
|
||||
## Step 3: Set Up the Build Pipeline
|
||||
|
||||
### Vite Configuration
|
||||
|
||||
Create `vite.config.ts` with `vite-plugin-singlefile` to bundle the UI into a single HTML file:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [viteSingleFile()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
input: 'mcp-app.html', // one per UI, or one shared entry
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### HTML Entry Point
|
||||
|
||||
Create `mcp-app.html` (or one per distinct UI if tools need different views):
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MCP App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/mcp-app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Build Scripts
|
||||
|
||||
Add build scripts to `package.json`. The UI must be built before the server code bundles it:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build:ui": "vite build",
|
||||
"build:server": "tsc",
|
||||
"build": "npm run build:ui && npm run build:server",
|
||||
"serve": "tsx server.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Convert Tools to App Tools
|
||||
|
||||
Transform plain MCP tools into App tools with UI.
|
||||
|
||||
**Before** (plain MCP tool):
|
||||
|
||||
```typescript
|
||||
server.tool('my-tool', { param: z.string() }, async (args) => {
|
||||
const data = await fetchData(args.param)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
|
||||
})
|
||||
```
|
||||
|
||||
**After** (App tool with UI):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
registerAppTool,
|
||||
registerAppResource,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from '@modelcontextprotocol/ext-apps/server'
|
||||
|
||||
const resourceUri = 'ui://my-tool/mcp-app.html'
|
||||
|
||||
registerAppTool(
|
||||
server,
|
||||
'my-tool',
|
||||
{
|
||||
description: 'Shows data with an interactive UI',
|
||||
inputSchema: { param: z.string() },
|
||||
_meta: { ui: { resourceUri } },
|
||||
},
|
||||
async (args) => {
|
||||
const data = await fetchData(args.param)
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data) }], // text fallback for non-UI hosts
|
||||
structuredContent: { data }, // structured data for the UI
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Key guidance:
|
||||
|
||||
- **Always keep the `content` array** with a text fallback for text-only clients
|
||||
- Add `structuredContent` for data the UI needs to render
|
||||
- Link the tool to its resource via `_meta.ui.resourceUri`
|
||||
- Leave tools that don't benefit from UI unchanged — they stay as plain tools
|
||||
|
||||
## Step 5: Register Resources
|
||||
|
||||
Register the HTML resource so the host can fetch it:
|
||||
|
||||
```typescript
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const resourceUri = 'ui://my-tool/mcp-app.html'
|
||||
|
||||
registerAppResource(
|
||||
server,
|
||||
{
|
||||
uri: resourceUri,
|
||||
name: 'My Tool UI',
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
},
|
||||
async () => {
|
||||
const html = await fs.readFile(
|
||||
path.resolve(import.meta.dirname, 'dist', 'mcp-app.html'),
|
||||
'utf-8'
|
||||
)
|
||||
return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
If multiple tools share the same UI, they can reference the same `resourceUri` and the same resource registration.
|
||||
|
||||
## Step 6: Build the UI
|
||||
|
||||
### Handler Registration
|
||||
|
||||
Register ALL handlers BEFORE calling `app.connect()`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
App,
|
||||
PostMessageTransport,
|
||||
applyDocumentTheme,
|
||||
applyHostStyleVariables,
|
||||
applyHostFonts,
|
||||
} from '@modelcontextprotocol/ext-apps'
|
||||
|
||||
const app = new App({ name: 'My App', version: '1.0.0' })
|
||||
|
||||
app.ontoolinput = (params) => {
|
||||
// Render the UI using params.arguments and/or params.structuredContent
|
||||
}
|
||||
|
||||
app.ontoolresult = (result) => {
|
||||
// Update UI with final tool result
|
||||
}
|
||||
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
if (ctx.theme) applyDocumentTheme(ctx.theme)
|
||||
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
|
||||
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
|
||||
if (ctx.safeAreaInsets) {
|
||||
const { top, right, bottom, left } = ctx.safeAreaInsets
|
||||
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
app.onteardown = async () => {
|
||||
return {}
|
||||
}
|
||||
|
||||
await app.connect(new PostMessageTransport())
|
||||
```
|
||||
|
||||
### Host Styling
|
||||
|
||||
Use host CSS variables for theme integration:
|
||||
|
||||
```css
|
||||
.container {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
```
|
||||
|
||||
Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for the full list.
|
||||
|
||||
For React apps, use the `useApp` and `useHostStyles` hooks instead — see `basic-server-react/` for the pattern.
|
||||
|
||||
## Optional Enhancements
|
||||
|
||||
### App-Only Helper Tools
|
||||
|
||||
Tools the UI calls but the model doesn't need to invoke directly (polling, pagination, chunk loading):
|
||||
|
||||
```typescript
|
||||
registerAppTool(
|
||||
server,
|
||||
'poll-data',
|
||||
{
|
||||
description: 'Polls latest data for the UI',
|
||||
_meta: { ui: { resourceUri, visibility: ['app'] } },
|
||||
},
|
||||
async () => {
|
||||
const data = await getLatestData()
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
The UI calls these via `app.callServerTool("poll-data", {})`.
|
||||
|
||||
### CSP Configuration
|
||||
|
||||
If the UI needs to load external resources (fonts, APIs, CDNs), declare the domains:
|
||||
|
||||
```typescript
|
||||
registerAppResource(
|
||||
server,
|
||||
{
|
||||
uri: resourceUri,
|
||||
name: 'My Tool UI',
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
_meta: {
|
||||
ui: {
|
||||
connectDomains: ['api.example.com'], // fetch/XHR targets
|
||||
resourceDomains: ['cdn.example.com'], // scripts, styles, images
|
||||
frameDomains: ['embed.example.com'], // nested iframes
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
/* ... */
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Streaming Partial Input
|
||||
|
||||
For large tool inputs, show progress during LLM generation:
|
||||
|
||||
```typescript
|
||||
app.ontoolinputpartial = (params) => {
|
||||
const args = params.arguments // Healed partial JSON - always valid
|
||||
// Render preview with partial data
|
||||
}
|
||||
|
||||
app.ontoolinput = (params) => {
|
||||
// Final complete input - switch to full render
|
||||
}
|
||||
```
|
||||
|
||||
### Graceful Degradation with `getUiCapability()`
|
||||
|
||||
Conditionally register App tools only when the client supports UI, falling back to text-only tools:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getUiCapability,
|
||||
registerAppTool,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from '@modelcontextprotocol/ext-apps/server'
|
||||
|
||||
server.server.oninitialized = () => {
|
||||
const clientCapabilities = server.server.getClientCapabilities()
|
||||
const uiCap = getUiCapability(clientCapabilities)
|
||||
|
||||
if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
|
||||
// Client supports UI — register App tool
|
||||
registerAppTool(
|
||||
server,
|
||||
'my-tool',
|
||||
{
|
||||
description: 'Shows data with interactive UI',
|
||||
_meta: { ui: { resourceUri } },
|
||||
},
|
||||
appToolHandler
|
||||
)
|
||||
} else {
|
||||
// Text-only client — register plain tool
|
||||
server.tool('my-tool', 'Shows data', { param: z.string() }, plainToolHandler)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fullscreen Mode
|
||||
|
||||
Allow the UI to expand to fullscreen:
|
||||
|
||||
```typescript
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
if (ctx.availableDisplayModes?.includes('fullscreen')) {
|
||||
fullscreenBtn.style.display = 'block'
|
||||
}
|
||||
if (ctx.displayMode) {
|
||||
container.classList.toggle('fullscreen', ctx.displayMode === 'fullscreen')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const newMode = currentMode === 'fullscreen' ? 'inline' : 'fullscreen'
|
||||
const result = await app.requestDisplayMode({ mode: newMode })
|
||||
currentMode = result.mode
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Forgetting text `content` fallback** — Always include `content` array with text for non-UI hosts
|
||||
2. **Registering handlers after `connect()`** — Register ALL handlers BEFORE calling `app.connect()`
|
||||
3. **Missing `vite-plugin-singlefile`** — Without it, assets won't load in the sandboxed iframe
|
||||
4. **Forgetting resource registration** — The tool references a `resourceUri` that must have a matching resource
|
||||
5. **Hardcoding styles** — Use host CSS variables (`var(--color-*)`) for theme integration
|
||||
6. **Not handling safe area insets** — Always apply `ctx.safeAreaInsets` in `onhostcontextchanged`
|
||||
|
||||
## Testing
|
||||
|
||||
### Using basic-host
|
||||
|
||||
Test the enhanced server with the basic-host example:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Build and run your server
|
||||
npm run build && npm run serve
|
||||
|
||||
# Terminal 2: Run basic-host (from cloned repo)
|
||||
cd /tmp/mcp-ext-apps/examples/basic-host
|
||||
npm install
|
||||
SERVERS='["http://localhost:3001/mcp"]' npm run start
|
||||
# Open http://localhost:8080
|
||||
```
|
||||
|
||||
Configure `SERVERS` with a JSON array of your server URLs (default: `http://localhost:3001/mcp`).
|
||||
|
||||
### Verify
|
||||
|
||||
1. Plain tools still work and return text output
|
||||
2. App tools render their UI in the iframe
|
||||
3. `ontoolinput` handler fires with tool arguments
|
||||
4. `ontoolresult` handler fires with tool result
|
||||
5. Host styling (theme, fonts, colors) applies correctly
|
||||
557
apps/mcp-app/.claude/skills/convert-web-app/SKILL.md
Normal file
557
apps/mcp-app/.claude/skills/convert-web-app/SKILL.md
Normal file
@@ -0,0 +1,557 @@
|
||||
---
|
||||
name: convert-web-app
|
||||
description: This skill should be used when the user asks to "add MCP App support to my web app", "turn my web app into a hybrid MCP App", "make my web page work as an MCP App too", "wrap my existing UI as an MCP App", "convert iframe embed to MCP App", "turn my SPA into an MCP App", or needs to add MCP App support to an existing web application while keeping it working standalone. Provides guidance for analyzing existing web apps and creating a hybrid web + MCP App with server-side tool and resource registration.
|
||||
---
|
||||
|
||||
# Add MCP App Support to a Web App
|
||||
|
||||
Add MCP App support to an existing web application so it works both as a standalone web app **and** as an MCP App that renders inline in MCP-enabled hosts like Claude Desktop — from a single codebase.
|
||||
|
||||
## How It Works
|
||||
|
||||
The existing web app stays intact. A thin initialization layer detects whether the app is running inside an MCP host or as a regular web page, and fetches parameters from the appropriate source. A new MCP server wraps the app's bundled HTML as a resource and registers a tool to display it.
|
||||
|
||||
```
|
||||
Standalone: Browser loads page → App reads URL params / APIs → renders
|
||||
MCP App: Host calls tool → Server returns result → Host renders app in iframe → App reads MCP lifecycle → renders
|
||||
```
|
||||
|
||||
The app's rendering logic is shared — only the data source changes.
|
||||
|
||||
## Getting Reference Code
|
||||
|
||||
Clone the SDK repository for working examples and API documentation:
|
||||
|
||||
```bash
|
||||
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
|
||||
```
|
||||
|
||||
### API Reference (Source Files)
|
||||
|
||||
Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`:
|
||||
|
||||
| File | Contents |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle |
|
||||
| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, tool visibility options |
|
||||
| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes |
|
||||
| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` |
|
||||
| `src/react/useApp.tsx` | `useApp` hook for React apps |
|
||||
| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks |
|
||||
|
||||
### Framework Templates
|
||||
|
||||
Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`:
|
||||
|
||||
| Template | Key Files |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` |
|
||||
| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) |
|
||||
| `basic-server-vue/` | `server.ts`, `src/App.vue` |
|
||||
| `basic-server-svelte/` | `server.ts`, `src/App.svelte` |
|
||||
| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
|
||||
### Reference Examples
|
||||
|
||||
| Example | Relevant Pattern |
|
||||
| ------------------------------ | -------------------------------------------------------------------- |
|
||||
| `examples/map-server/` | External API integration + CSP (`connectDomains`, `resourceDomains`) |
|
||||
| `examples/sheet-music-server/` | Library that loads external assets (soundfonts) |
|
||||
| `examples/pdf-server/` | Binary content handling + app-only helper tools |
|
||||
|
||||
## Step 1: Analyze the Existing Web App
|
||||
|
||||
Before writing any code, examine the existing web app to plan what needs to change.
|
||||
|
||||
### What to Investigate
|
||||
|
||||
1. **Data sources** — How does the app get its data? (URL params, API calls, props, hardcoded, localStorage)
|
||||
2. **External dependencies** — CDN scripts, fonts, API endpoints, iframe embeds, WebSocket connections
|
||||
3. **Build system** — Current bundler (Webpack, Vite, Rollup, none), framework (React, Vue, vanilla), entry points
|
||||
4. **User interactions** — Does the app have inputs/forms that should map to tool parameters?
|
||||
5. **Runtime detection** — How to tell if the app is running inside an MCP host (e.g., check the current origin, a query param, or whether `window.parent !== window`)
|
||||
|
||||
Present findings to the user and confirm the approach.
|
||||
|
||||
### Data Source Mapping
|
||||
|
||||
In hybrid mode, the app keeps its existing data sources for standalone use and adds MCP equivalents:
|
||||
|
||||
| Standalone data source | MCP App equivalent |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| URL query parameters | `ontoolinput` / `ontoolresult` `arguments` or `structuredContent` |
|
||||
| REST API calls | `app.callServerTool()` to server-side tools, or keep direct API calls with CSP `connectDomains` |
|
||||
| Props / component inputs | `ontoolinput` `arguments` |
|
||||
| localStorage / sessionStorage | Not available in sandboxed iframe — pass via `structuredContent` or server-side state |
|
||||
| WebSocket connections | Keep with CSP `connectDomains`, or convert to polling via app-only tools |
|
||||
| Hardcoded data | Move to tool `structuredContent` to make it dynamic |
|
||||
|
||||
## Step 2: Investigate CSP Requirements
|
||||
|
||||
MCP Apps HTML runs in a sandboxed iframe with no same-origin server. **Every** external origin must be declared in CSP — missing origins fail silently.
|
||||
|
||||
**Before writing any code**, build the app and investigate all origins it references:
|
||||
|
||||
1. Build the app using the existing build command
|
||||
2. Search the resulting HTML, CSS, and JS for **every** origin (not just "external" origins — every network request will need CSP approval)
|
||||
3. For each origin found, trace back to source:
|
||||
- If it comes from a constant → universal (same in dev and prod)
|
||||
- If it comes from an env var or conditional → note the mechanism and identify both dev and prod values
|
||||
4. Check for third-party libraries that may make their own requests (analytics, error tracking, etc.)
|
||||
|
||||
**Document your findings** as three lists, and note for each origin whether it's universal, dev-only, or prod-only:
|
||||
|
||||
- **resourceDomains**: origins serving images, fonts, styles, scripts
|
||||
- **connectDomains**: origins for API/fetch requests
|
||||
- **frameDomains**: origins for nested iframes
|
||||
|
||||
If no origins are found, the app may not need custom CSP domains.
|
||||
|
||||
## Step 3: Set Up the MCP Server
|
||||
|
||||
Create a new MCP server with tool and resource registration. This wraps the existing web app for MCP hosts.
|
||||
|
||||
### Dependencies
|
||||
|
||||
```bash
|
||||
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
|
||||
npm install -D tsx vite vite-plugin-singlefile
|
||||
```
|
||||
|
||||
Use `npm install` to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
|
||||
|
||||
### Server Code
|
||||
|
||||
Create `server.ts`:
|
||||
|
||||
```typescript
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import {
|
||||
registerAppTool,
|
||||
registerAppResource,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from '@modelcontextprotocol/ext-apps/server'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { z } from 'zod'
|
||||
|
||||
const server = new McpServer({ name: 'my-app', version: '1.0.0' })
|
||||
|
||||
const resourceUri = 'ui://my-app/mcp-app.html'
|
||||
|
||||
// Register the tool — inputSchema maps to the app's data sources
|
||||
registerAppTool(
|
||||
server,
|
||||
'show-app',
|
||||
{
|
||||
description: 'Displays the app with the given parameters',
|
||||
inputSchema: { query: z.string().describe('The search query') },
|
||||
_meta: { ui: { resourceUri } },
|
||||
},
|
||||
async (args) => {
|
||||
// Process args server-side if needed
|
||||
return {
|
||||
content: [{ type: 'text', text: `Showing app for: ${args.query}` }],
|
||||
structuredContent: { query: args.query },
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Register the HTML resource
|
||||
registerAppResource(
|
||||
server,
|
||||
{
|
||||
uri: resourceUri,
|
||||
name: 'My App UI',
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
// Add CSP domains from Step 2 if needed:
|
||||
// _meta: { ui: { connectDomains: ["api.example.com"], resourceDomains: ["cdn.example.com"] } },
|
||||
},
|
||||
async () => {
|
||||
const html = await fs.readFile(
|
||||
path.resolve(import.meta.dirname, 'dist', 'mcp-app.html'),
|
||||
'utf-8'
|
||||
)
|
||||
return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }
|
||||
}
|
||||
)
|
||||
|
||||
// Start the server
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
```
|
||||
|
||||
### Package Scripts
|
||||
|
||||
Add to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build:ui": "vite build",
|
||||
"build:server": "tsc",
|
||||
"build": "npm run build:ui && npm run build:server",
|
||||
"serve": "tsx server.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Adapt the Build Pipeline
|
||||
|
||||
The MCP App build must produce a single HTML file using `vite-plugin-singlefile`. The standalone web app build stays unchanged.
|
||||
|
||||
### Vite Configuration
|
||||
|
||||
Create or update `vite.config.ts`. If the app already uses Vite, add `vite-plugin-singlefile` and a separate entry point for the MCP App build. If it uses another bundler, add a Vite config alongside for the MCP App build only.
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [viteSingleFile()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
input: 'mcp-app.html',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Add framework-specific Vite plugins as needed (e.g., `@vitejs/plugin-react` for React, `@vitejs/plugin-vue` for Vue).
|
||||
|
||||
### HTML Entry Point
|
||||
|
||||
Create `mcp-app.html` as a separate entry point for the MCP App build. This can point to the same app code — the runtime detection handles the rest:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MCP App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Two-Phase Build
|
||||
|
||||
1. Vite bundles the UI → `dist/mcp-app.html` (single file with all assets inlined)
|
||||
2. Server is compiled separately (TypeScript → JavaScript)
|
||||
|
||||
The standalone web app continues to build and deploy as before.
|
||||
|
||||
## Step 5: Add MCP App Initialization Alongside Existing Logic
|
||||
|
||||
This is the core step. Instead of replacing the app's data sources, add an alternative initialization path for MCP mode. The app detects its environment at startup and reads parameters from the right source.
|
||||
|
||||
### The Hybrid Pattern
|
||||
|
||||
```typescript
|
||||
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps'
|
||||
|
||||
// Detect whether we're running inside an MCP host.
|
||||
// Choose a detection method that fits the app:
|
||||
// - Origin check: window.location.origin !== 'https://myhost.com'
|
||||
// - Null origin (sandboxed iframe): window.location.origin === 'null'
|
||||
// - Query param: new URL(location.href).searchParams.has('mcp')
|
||||
const isMcpApp = window.location.origin === 'null'
|
||||
|
||||
async function getParameters(): Promise<Record<string, string>> {
|
||||
if (isMcpApp) {
|
||||
// Running as MCP App — get params from tool lifecycle
|
||||
const app = new App({ name: 'My App', version: '1.0.0' })
|
||||
|
||||
// Register handlers BEFORE connect()
|
||||
const params = await new Promise<Record<string, string>>((resolve) => {
|
||||
app.ontoolresult = (result) => resolve(result.structuredContent ?? {})
|
||||
})
|
||||
|
||||
await app.connect(new PostMessageTransport())
|
||||
return params
|
||||
} else {
|
||||
// Running as standalone web app — get params from URL
|
||||
return Object.fromEntries(new URL(location.href).searchParams)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const params = await getParameters()
|
||||
renderApp(params) // Same rendering logic for both modes
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
```
|
||||
|
||||
### URL Parameters (Hybrid)
|
||||
|
||||
```typescript
|
||||
// Before (standalone only):
|
||||
const query = new URL(location.href).searchParams.get('q')
|
||||
renderApp(query)
|
||||
|
||||
// After (hybrid):
|
||||
async function getQuery(): Promise<string> {
|
||||
if (isMcpApp) {
|
||||
const app = new App({ name: 'My App', version: '1.0.0' })
|
||||
return new Promise((resolve) => {
|
||||
app.ontoolinput = (params) => resolve(params.arguments?.q ?? '')
|
||||
app.connect(new PostMessageTransport())
|
||||
})
|
||||
}
|
||||
return new URL(location.href).searchParams.get('q') ?? ''
|
||||
}
|
||||
|
||||
const query = await getQuery()
|
||||
renderApp(query) // Unchanged rendering logic
|
||||
```
|
||||
|
||||
### API Calls (Hybrid)
|
||||
|
||||
```typescript
|
||||
// Before (standalone only):
|
||||
const data = await fetch('/api/data').then((r) => r.json())
|
||||
|
||||
// After (hybrid):
|
||||
async function fetchData(): Promise<any> {
|
||||
if (isMcpApp) {
|
||||
const result = await app.callServerTool('fetch-data', {})
|
||||
return result.structuredContent
|
||||
}
|
||||
return fetch('/api/data').then((r) => r.json())
|
||||
}
|
||||
```
|
||||
|
||||
Or keep direct API calls in both modes with CSP `connectDomains`:
|
||||
|
||||
```typescript
|
||||
// API calls can stay unchanged if the API is external and the CSP declares the domain
|
||||
// Declare connectDomains: ["api.example.com"] in the resource registration
|
||||
```
|
||||
|
||||
### localStorage / sessionStorage (Hybrid)
|
||||
|
||||
```typescript
|
||||
// Before (standalone only):
|
||||
const saved = localStorage.getItem('settings')
|
||||
|
||||
// After (hybrid) — localStorage isn't available in sandboxed iframes:
|
||||
function getSettings(): any {
|
||||
if (isMcpApp) {
|
||||
// Will be provided via tool result
|
||||
return null // or a default
|
||||
}
|
||||
return JSON.parse(localStorage.getItem('settings') ?? 'null')
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Hybrid Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
App,
|
||||
PostMessageTransport,
|
||||
applyDocumentTheme,
|
||||
applyHostStyleVariables,
|
||||
applyHostFonts,
|
||||
} from '@modelcontextprotocol/ext-apps'
|
||||
|
||||
const isMcpApp = window.location.origin === 'null'
|
||||
|
||||
async function initMcpApp(): Promise<Record<string, any>> {
|
||||
const app = new App({ name: 'My App', version: '1.0.0' })
|
||||
|
||||
// Register ALL handlers BEFORE connect()
|
||||
const params = await new Promise<Record<string, any>>((resolve) => {
|
||||
app.ontoolinput = (input) => resolve(input.arguments ?? {})
|
||||
})
|
||||
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
if (ctx.theme) applyDocumentTheme(ctx.theme)
|
||||
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
|
||||
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
|
||||
if (ctx.safeAreaInsets) {
|
||||
const { top, right, bottom, left } = ctx.safeAreaInsets
|
||||
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
app.onteardown = async () => {
|
||||
return {}
|
||||
}
|
||||
|
||||
await app.connect(new PostMessageTransport())
|
||||
return params
|
||||
}
|
||||
|
||||
async function initStandaloneApp(): Promise<Record<string, any>> {
|
||||
return Object.fromEntries(new URL(location.href).searchParams)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const params = isMcpApp ? await initMcpApp() : await initStandaloneApp()
|
||||
renderApp(params) // Same rendering logic — no fork needed
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
```
|
||||
|
||||
## Step 6: Add Host Styling Integration (MCP Mode Only)
|
||||
|
||||
When running as an MCP App, integrate with host styling for theme consistency. Use CSS variable fallbacks so the app looks correct in both modes.
|
||||
|
||||
**Vanilla JS** — use helper functions:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
applyDocumentTheme,
|
||||
applyHostStyleVariables,
|
||||
applyHostFonts,
|
||||
} from '@modelcontextprotocol/ext-apps'
|
||||
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
if (ctx.theme) applyDocumentTheme(ctx.theme)
|
||||
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
|
||||
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
|
||||
}
|
||||
```
|
||||
|
||||
**React** — use hooks:
|
||||
|
||||
```typescript
|
||||
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react'
|
||||
|
||||
const { app } = useApp({ appInfo, capabilities, onAppCreated })
|
||||
useHostStyles(app)
|
||||
```
|
||||
|
||||
**Using variables in CSS** — use `var()` with fallbacks so standalone mode still looks right:
|
||||
|
||||
```css
|
||||
.container {
|
||||
background: var(--color-background-secondary, #f5f5f5);
|
||||
color: var(--color-text-primary, #333);
|
||||
font-family: var(--font-sans, system-ui);
|
||||
border-radius: var(--border-radius-md, 8px);
|
||||
}
|
||||
```
|
||||
|
||||
Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for the full list.
|
||||
|
||||
## Optional Enhancements
|
||||
|
||||
### App-Only Helper Tools
|
||||
|
||||
For data the UI needs to poll or fetch that the model doesn't need to call directly:
|
||||
|
||||
```typescript
|
||||
registerAppTool(
|
||||
server,
|
||||
'refresh-data',
|
||||
{
|
||||
description: 'Fetches latest data for the UI',
|
||||
_meta: { ui: { resourceUri, visibility: ['app'] } },
|
||||
},
|
||||
async () => {
|
||||
const data = await getLatestData()
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
The UI calls these via `app.callServerTool("refresh-data", {})`.
|
||||
|
||||
### Streaming Partial Input
|
||||
|
||||
For large tool inputs, use `ontoolinputpartial` to show progress during LLM generation:
|
||||
|
||||
```typescript
|
||||
app.ontoolinputpartial = (params) => {
|
||||
const args = params.arguments // Healed partial JSON - always valid
|
||||
renderPreview(args)
|
||||
}
|
||||
|
||||
app.ontoolinput = (params) => {
|
||||
renderFull(params.arguments)
|
||||
}
|
||||
```
|
||||
|
||||
### Fullscreen Mode
|
||||
|
||||
```typescript
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
if (ctx.availableDisplayModes?.includes('fullscreen')) {
|
||||
fullscreenBtn.style.display = 'block'
|
||||
}
|
||||
if (ctx.displayMode) {
|
||||
container.classList.toggle('fullscreen', ctx.displayMode === 'fullscreen')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const newMode = currentMode === 'fullscreen' ? 'inline' : 'fullscreen'
|
||||
const result = await app.requestDisplayMode({ mode: newMode })
|
||||
currentMode = result.mode
|
||||
}
|
||||
```
|
||||
|
||||
### Text Fallback
|
||||
|
||||
Always provide a `content` array for non-UI hosts:
|
||||
|
||||
```typescript
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Fallback description of the result' }],
|
||||
structuredContent: {
|
||||
/* data for the UI */
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Forgetting CSP declarations for external origins** — fails silently in the sandboxed iframe
|
||||
2. **Using `localStorage` / `sessionStorage` in MCP mode** — not available in sandboxed iframe; use fallbacks or pass via `structuredContent`
|
||||
3. **Missing `vite-plugin-singlefile`** — external assets won't load in the iframe
|
||||
4. **Registering handlers after `connect()`** — register ALL handlers BEFORE calling `app.connect()`
|
||||
5. **Hardcoding styles without fallbacks** — use host CSS variables with `var(..., fallback)` so both modes look correct
|
||||
6. **Not handling safe area insets** — always apply `ctx.safeAreaInsets` in `onhostcontextchanged`
|
||||
7. **Forgetting text `content` fallback** — always provide `content` array for non-UI hosts
|
||||
8. **Forgetting resource registration** — the tool references a `resourceUri` that must have a matching resource
|
||||
9. **Replacing standalone logic instead of branching** — keep the original data sources intact; add the MCP path alongside them
|
||||
|
||||
## Testing
|
||||
|
||||
### Using basic-host
|
||||
|
||||
Test the MCP App mode with the basic-host example:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Build and run your server
|
||||
npm run build && npm run serve
|
||||
|
||||
# Terminal 2: Run basic-host (from cloned repo)
|
||||
cd /tmp/mcp-ext-apps/examples/basic-host
|
||||
npm install
|
||||
SERVERS='["http://localhost:3001/mcp"]' npm run start
|
||||
# Open http://localhost:8080
|
||||
```
|
||||
|
||||
Configure `SERVERS` with a JSON array of your server URLs (default: `http://localhost:3001/mcp`).
|
||||
|
||||
### Verify
|
||||
|
||||
1. **MCP mode**: App loads in basic-host without console errors
|
||||
2. `ontoolinput` handler fires with tool arguments
|
||||
3. `ontoolresult` handler fires with tool result
|
||||
4. Host styling (theme, fonts, colors) applies correctly
|
||||
5. External resources load (if CSP domains are configured)
|
||||
6. **Standalone mode**: App still works when opened directly in a browser
|
||||
367
apps/mcp-app/.claude/skills/create-mcp-app/SKILL.md
Normal file
367
apps/mcp-app/.claude/skills/create-mcp-app/SKILL.md
Normal file
@@ -0,0 +1,367 @@
|
||||
---
|
||||
name: create-mcp-app
|
||||
description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs.
|
||||
---
|
||||
|
||||
# Create MCP App
|
||||
|
||||
Build interactive UIs that run inside MCP-enabled hosts like Claude Desktop. An MCP App combines an MCP tool with an HTML resource to display rich, interactive content.
|
||||
|
||||
## Core Concept: Tool + Resource
|
||||
|
||||
Every MCP App requires two parts linked together:
|
||||
|
||||
1. **Tool** - Called by the LLM/host, returns data
|
||||
2. **Resource** - Serves the bundled HTML UI that displays the data
|
||||
3. **Link** - The tool's `_meta.ui.resourceUri` references the resource
|
||||
|
||||
```
|
||||
Host calls tool → Server returns result → Host renders resource UI → UI receives result
|
||||
```
|
||||
|
||||
## Quick Start Decision Tree
|
||||
|
||||
### Framework Selection
|
||||
|
||||
| Framework | SDK Support | Best For |
|
||||
| ----------------------- | ---------------------- | -------------------------------- |
|
||||
| React | `useApp` hook provided | Teams familiar with React |
|
||||
| Vanilla JS | Manual lifecycle | Simple apps, no build complexity |
|
||||
| Vue/Svelte/Preact/Solid | Manual lifecycle | Framework preference |
|
||||
|
||||
### Project Context
|
||||
|
||||
**Adding to existing MCP server:**
|
||||
|
||||
- Import `registerAppTool`, `registerAppResource` from SDK
|
||||
- Add tool registration with `_meta.ui.resourceUri`
|
||||
- Add resource registration serving bundled HTML
|
||||
|
||||
**Creating new MCP server:**
|
||||
|
||||
- Set up server with transport (stdio or HTTP)
|
||||
- Register tools and resources
|
||||
- Configure build system with `vite-plugin-singlefile`
|
||||
|
||||
## Getting Reference Code
|
||||
|
||||
Clone the SDK repository for working examples and API documentation:
|
||||
|
||||
```bash
|
||||
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
|
||||
```
|
||||
|
||||
### Framework Templates
|
||||
|
||||
Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`:
|
||||
|
||||
| Template | Key Files |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` |
|
||||
| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) |
|
||||
| `basic-server-vue/` | `server.ts`, `src/App.vue` |
|
||||
| `basic-server-svelte/` | `server.ts`, `src/App.svelte` |
|
||||
| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
|
||||
Each template includes:
|
||||
|
||||
- Complete `server.ts` with `registerAppTool` and `registerAppResource`
|
||||
- Client-side app with all lifecycle handlers
|
||||
- `vite.config.ts` with `vite-plugin-singlefile`
|
||||
- `package.json` with all required dependencies
|
||||
- `.gitignore` excluding `node_modules/` and `dist/`
|
||||
|
||||
### API Reference (Source Files)
|
||||
|
||||
Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`:
|
||||
|
||||
| File | Contents |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle |
|
||||
| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, tool visibility options |
|
||||
| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes |
|
||||
| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` |
|
||||
| `src/react/useApp.tsx` | `useApp` hook for React apps |
|
||||
| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks |
|
||||
|
||||
### Advanced Examples
|
||||
|
||||
| Example | Pattern Demonstrated |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `examples/shadertoy-server/` | **Streaming partial input** + visibility-based pause/play (best practice for large inputs) |
|
||||
| `examples/wiki-explorer-server/` | `callServerTool` for interactive data fetching |
|
||||
| `examples/system-monitor-server/` | Polling pattern with interval management |
|
||||
| `examples/video-resource-server/` | Binary/blob resources |
|
||||
| `examples/sheet-music-server/` | `ontoolinput` - processing tool args before execution completes |
|
||||
| `examples/threejs-server/` | `ontoolinputpartial` - streaming/progressive rendering |
|
||||
| `examples/map-server/` | `updateModelContext` - keeping model informed of UI state |
|
||||
| `examples/transcript-server/` | `updateModelContext` + `sendMessage` - background context updates + user-initiated messages |
|
||||
| `examples/basic-host/` | Reference host implementation using `AppBridge` |
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
### Adding Dependencies
|
||||
|
||||
Use `npm install` to add dependencies rather than manually writing version numbers:
|
||||
|
||||
```bash
|
||||
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
|
||||
```
|
||||
|
||||
This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
|
||||
|
||||
### TypeScript Server Execution
|
||||
|
||||
Use `tsx` as a devDependency for running TypeScript server files:
|
||||
|
||||
```bash
|
||||
npm install -D tsx
|
||||
```
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"serve": "tsx server.ts"
|
||||
}
|
||||
```
|
||||
|
||||
Note: The SDK examples use `bun` but generated projects should use `tsx` for broader compatibility.
|
||||
|
||||
### Handler Registration Order
|
||||
|
||||
Register ALL handlers BEFORE calling `app.connect()`:
|
||||
|
||||
```typescript
|
||||
const app = new App({ name: 'My App', version: '1.0.0' })
|
||||
|
||||
// Register handlers first
|
||||
app.ontoolinput = (params) => {
|
||||
/* handle input */
|
||||
}
|
||||
app.ontoolresult = (result) => {
|
||||
/* handle result */
|
||||
}
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
/* handle context */
|
||||
}
|
||||
app.onteardown = async () => {
|
||||
return {}
|
||||
}
|
||||
|
||||
// Then connect
|
||||
await app.connect()
|
||||
```
|
||||
|
||||
### Tool Visibility
|
||||
|
||||
Control who can access tools via `_meta.ui.visibility`:
|
||||
|
||||
```typescript
|
||||
// Default: visible to both model and app
|
||||
_meta: { ui: { resourceUri, visibility: ["model", "app"] } }
|
||||
|
||||
// UI-only (hidden from model) - for refresh buttons, form submissions
|
||||
_meta: { ui: { resourceUri, visibility: ["app"] } }
|
||||
|
||||
// Model-only (app cannot call)
|
||||
_meta: { ui: { resourceUri, visibility: ["model"] } }
|
||||
```
|
||||
|
||||
### Host Styling Integration
|
||||
|
||||
**Vanilla JS** - Use helper functions:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
applyDocumentTheme,
|
||||
applyHostStyleVariables,
|
||||
applyHostFonts,
|
||||
} from '@modelcontextprotocol/ext-apps'
|
||||
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
if (ctx.theme) applyDocumentTheme(ctx.theme)
|
||||
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
|
||||
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
|
||||
}
|
||||
```
|
||||
|
||||
**React** - Use hooks:
|
||||
|
||||
```typescript
|
||||
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react'
|
||||
|
||||
const { app } = useApp({ appInfo, capabilities, onAppCreated })
|
||||
useHostStyles(app) // Injects CSS variables to document, making var(--*) available
|
||||
```
|
||||
|
||||
**Using variables in CSS** - After applying, use `var()`:
|
||||
|
||||
```css
|
||||
.container {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
.code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-text-sm-size);
|
||||
line-height: var(--font-text-sm-line-height);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--font-heading-lg-size);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
```
|
||||
|
||||
Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for full list.
|
||||
|
||||
### Safe Area Handling
|
||||
|
||||
Always respect `safeAreaInsets`:
|
||||
|
||||
```typescript
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
if (ctx.safeAreaInsets) {
|
||||
const { top, right, bottom, left } = ctx.safeAreaInsets
|
||||
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Partial Input
|
||||
|
||||
For large tool inputs, use `ontoolinputpartial` to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.
|
||||
|
||||
**Spec:** [ui/notifications/tool-input-partial](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#streaming-tool-input)
|
||||
|
||||
```typescript
|
||||
app.ontoolinputpartial = (params) => {
|
||||
const args = params.arguments // Healed partial JSON - always valid, fields appear as generated
|
||||
// Use args directly for progressive rendering
|
||||
}
|
||||
|
||||
app.ontoolinput = (params) => {
|
||||
// Final complete input - switch from preview to full render
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
| Pattern | Example |
|
||||
|---------|---------|
|
||||
| Code preview | Show streaming code in `<pre>`, render on complete (`examples/shadertoy-server/`) |
|
||||
| Progressive form | Fill form fields as they stream in |
|
||||
| Live chart | Add data points to chart as array grows |
|
||||
| Partial render | Render incomplete structured data (tables, lists, trees) |
|
||||
|
||||
**Simple pattern (code preview):**
|
||||
|
||||
```typescript
|
||||
app.ontoolinputpartial = (params) => {
|
||||
codePreview.textContent = params.arguments?.code ?? ''
|
||||
codePreview.style.display = 'block'
|
||||
canvas.style.display = 'none'
|
||||
}
|
||||
app.ontoolinput = (params) => {
|
||||
codePreview.style.display = 'none'
|
||||
canvas.style.display = 'block'
|
||||
render(params.arguments)
|
||||
}
|
||||
```
|
||||
|
||||
### Visibility-Based Resource Management
|
||||
|
||||
Pause expensive operations (animations, WebGL, polling) when view scrolls out of viewport:
|
||||
|
||||
```typescript
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
animation.play() // or: startPolling(), shaderToy.play()
|
||||
} else {
|
||||
animation.pause() // or: stopPolling(), shaderToy.pause()
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe(document.querySelector('.main'))
|
||||
```
|
||||
|
||||
### Fullscreen Mode
|
||||
|
||||
Request fullscreen via `app.requestDisplayMode()`. Check availability in host context:
|
||||
|
||||
```typescript
|
||||
let currentMode: 'inline' | 'fullscreen' = 'inline'
|
||||
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
// Check if fullscreen available
|
||||
if (ctx.availableDisplayModes?.includes('fullscreen')) {
|
||||
fullscreenBtn.style.display = 'block'
|
||||
}
|
||||
// Track current mode
|
||||
if (ctx.displayMode) {
|
||||
currentMode = ctx.displayMode
|
||||
container.classList.toggle('fullscreen', currentMode === 'fullscreen')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const newMode = currentMode === 'fullscreen' ? 'inline' : 'fullscreen'
|
||||
const result = await app.requestDisplayMode({ mode: newMode })
|
||||
currentMode = result.mode
|
||||
}
|
||||
```
|
||||
|
||||
**CSS pattern** - Remove border radius in fullscreen:
|
||||
|
||||
```css
|
||||
.main {
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.main.fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
```
|
||||
|
||||
See `examples/shadertoy-server/` for complete implementation.
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Handlers after connect()** - Register ALL handlers BEFORE calling `app.connect()`
|
||||
2. **Missing single-file bundling** - Must use `vite-plugin-singlefile`
|
||||
3. **Forgetting resource registration** - Both tool AND resource must be registered
|
||||
4. **Missing resourceUri link** - Tool must have `_meta.ui.resourceUri`
|
||||
5. **Ignoring safe area insets** - Always handle `ctx.safeAreaInsets`
|
||||
6. **No text fallback** - Always provide `content` array for non-UI hosts
|
||||
7. **Hardcoded styles** - Use host CSS variables for theme integration
|
||||
8. **No streaming for large inputs** - Use `ontoolinputpartial` to show progress during generation
|
||||
|
||||
## Testing
|
||||
|
||||
### Using basic-host
|
||||
|
||||
Test MCP Apps locally with the basic-host example:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Build and run your server
|
||||
npm run build && npm run serve
|
||||
|
||||
# Terminal 2: Run basic-host (from cloned repo)
|
||||
cd /tmp/mcp-ext-apps/examples/basic-host
|
||||
npm install
|
||||
SERVERS='["http://localhost:3001/mcp"]' npm run start
|
||||
# Open http://localhost:8080
|
||||
```
|
||||
|
||||
Configure `SERVERS` with a JSON array of your server URLs (default: `http://localhost:3001/mcp`).
|
||||
|
||||
### Debug with sendLog
|
||||
|
||||
Send debug logs to the host application (rather than just the iframe's dev console):
|
||||
|
||||
```typescript
|
||||
await app.sendLog({ level: 'info', data: 'Debug message' })
|
||||
await app.sendLog({ level: 'error', data: { error: err.message } })
|
||||
```
|
||||
170
apps/mcp-app/.claude/skills/migrate-oai-app/SKILL.md
Normal file
170
apps/mcp-app/.claude/skills/migrate-oai-app/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: migrate-oai-app
|
||||
description: This skill should be used when the user asks to "migrate from OpenAI Apps SDK", "convert OpenAI App to MCP", "port from window.openai", "migrate from skybridge", "convert openai/outputTemplate", or needs guidance on converting OpenAI Apps SDK applications to MCP Apps SDK. Provides step-by-step migration guidance with API mapping tables.
|
||||
---
|
||||
|
||||
# Migrate OpenAI App to MCP
|
||||
|
||||
Migrate existing OpenAI Apps SDK applications to the MCP Apps SDK (`@modelcontextprotocol/ext-apps`). The MCP Apps SDK provides a standardized, open protocol for interactive UIs in conversational clients.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use your package manager to add dependencies (e.g., `npm install`, `pnpm add`, `yarn add`) instead of manually writing version numbers. This lets the package manager resolve the latest compatible versions. Never specify version numbers from memory.
|
||||
- Preemptively add a final todo item with this exact wording: "Re-read the 'Before Finishing' checklist in this skill and address each checkbox individually, stating what you did for each one, before marking this todo complete."
|
||||
|
||||
## Getting Reference Code
|
||||
|
||||
Clone the SDK repository for complete migration documentation and working examples:
|
||||
|
||||
```bash
|
||||
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
|
||||
```
|
||||
|
||||
### Migration Reference Guide
|
||||
|
||||
Read the migration reference guide with "before/after" mapping tables: `/tmp/mcp-ext-apps/docs/migrate_from_openai_apps.md`
|
||||
|
||||
### API Reference (Source Files)
|
||||
|
||||
Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/*`:
|
||||
|
||||
| File | Contents |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| `src/app.ts` | `App` class, handlers, lifecycle |
|
||||
| `src/server/index.ts` | `registerAppTool`, `registerAppResource` |
|
||||
| `src/spec.types.ts` | Type definitions |
|
||||
| `src/react/useApp.tsx` | `useApp` hook for React apps |
|
||||
| `src/react/use*.ts*` | Other `use*` hooks for React apps |
|
||||
|
||||
### Front-End Framework Examples
|
||||
|
||||
See `/tmp/mcp-ext-apps/examples/basic-server-{framework}/` for basic SDK usage examples organized by front-end framework:
|
||||
|
||||
| Template | Key Files |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` |
|
||||
| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) |
|
||||
| `basic-server-vue/` | `server.ts`, `src/App.vue` |
|
||||
| `basic-server-svelte/` | `server.ts`, `src/App.svelte` |
|
||||
| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |
|
||||
|
||||
## CSP Investigation
|
||||
|
||||
MCP Apps HTML is served as an MCP resource, not as a web page, and runs in a sandboxed iframe with no same-origin server. **Every** origin must be declared in CSP—including the origin serving your JS/CSS bundles (`localhost` in dev, your CDN in production). Missing origins fail silently.
|
||||
|
||||
**Before writing any migration code**, build the app and investigate all origins it references:
|
||||
|
||||
1. Build the app using the existing build command
|
||||
2. Search the resulting HTML, CSS, and JS for **every** origin (not just "external" origins—every network request will need CSP approval)
|
||||
3. For each origin found, trace back to source:
|
||||
- If it comes from a constant → universal (same in dev and prod)
|
||||
- If it comes from an env var or conditional → note the mechanism and identify both dev and prod values
|
||||
4. Check for third-party libraries that may make their own requests (analytics, error tracking, etc.)
|
||||
|
||||
**Document your findings** as three lists, and note for each origin whether it's universal, dev-only, or prod-only:
|
||||
|
||||
- **resourceDomains**: origins serving images, fonts, styles, scripts
|
||||
- **connectDomains**: origins for API/fetch requests
|
||||
- **frameDomains**: origins for nested iframes
|
||||
|
||||
If no origins are found, the app may not need custom CSP domains.
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
MCP clients make cross-origin requests. If using Express, `app.use(cors())` handles this.
|
||||
|
||||
For raw HTTP servers, configure standard CORS and additionally:
|
||||
|
||||
- Allow headers: `mcp-session-id`, `mcp-protocol-version`, `last-event-id`
|
||||
- Expose headers: `mcp-session-id`
|
||||
|
||||
## Key Conceptual Changes
|
||||
|
||||
### Server-Side
|
||||
|
||||
Use `registerAppTool()` and `registerAppResource()` helpers instead of raw `server.registerTool()` / `server.registerResource()`. These helpers handle the MCP Apps metadata format automatically.
|
||||
|
||||
See `/tmp/mcp-ext-apps/docs/migrate_from_openai_apps.md` for server-side mapping tables.
|
||||
|
||||
### Client-Side
|
||||
|
||||
The fundamental paradigm shift: OpenAI uses a synchronous global object (`window.openai.toolInput`, `window.openai.theme`) that's pre-populated before your code runs. MCP Apps uses an `App` instance with async event handlers.
|
||||
|
||||
Key differences:
|
||||
|
||||
- Create an `App` instance and register handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`) **before** calling `connect()`. (Events may fire immediately after connection, so handlers must be registered first.)
|
||||
- Access tool data via handlers: `app.ontoolinput` for `window.openai.toolInput`, `app.ontoolresult` for `window.openai.toolOutput`.
|
||||
- Access host environment (theme, locale, etc.) via `app.getHostContext()`.
|
||||
|
||||
For React apps, the `useApp` hook manages this lifecycle automatically—see `basic-server-react/` for the pattern.
|
||||
|
||||
See `/tmp/mcp-ext-apps/docs/migrate_from_openai_apps.md` for client-side mapping tables.
|
||||
|
||||
### Features Not Yet Available in MCP Apps
|
||||
|
||||
These OpenAI features don't have MCP equivalents yet:
|
||||
|
||||
**Server-side:**
|
||||
| OpenAI Feature | Status/Workaround |
|
||||
|----------------|-------------------|
|
||||
| `_meta["openai/toolInvocation/invoking"]` / `_meta["openai/toolInvocation/invoked"]` | Progress indicators not yet available |
|
||||
| `_meta["openai/widgetDescription"]` | Use `app.updateModelContext()` for dynamic context |
|
||||
|
||||
**Client-side:**
|
||||
| OpenAI Feature | Status/Workaround |
|
||||
|----------------|-------------------|
|
||||
| `window.openai.widgetState` / `setWidgetState()` | Use `localStorage` or server-side state |
|
||||
| `window.openai.uploadFile()` / `getFileDownloadUrl()` | File operations not yet available |
|
||||
| `window.openai.requestModal()` / `requestClose()` | Modal management not yet available |
|
||||
| `window.openai.view` | Not yet available |
|
||||
|
||||
## Before Finishing
|
||||
|
||||
Slow down and carefully follow each item in this checklist:
|
||||
|
||||
- [ ] Search for and migrate any remaining server-side OpenAI patterns:
|
||||
|
||||
| Pattern | Indicates |
|
||||
| --------------------------- | ----------------------------------------------------------------- |
|
||||
| `"openai/` | Old metadata keys → `_meta.ui.*` |
|
||||
| `text/html+skybridge` | Old MIME type → `RESOURCE_MIME_TYPE` constant |
|
||||
| `text/html;profile=mcp-app` | New MIME type, but prefer `RESOURCE_MIME_TYPE` constant |
|
||||
| `_domains"` or `_domains:` | snake_case CSP → camelCase (`connect_domains` → `connectDomains`) |
|
||||
|
||||
- [ ] Search for and migrate any remaining client-side OpenAI patterns:
|
||||
|
||||
| Pattern | Indicates |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| `window.openai.toolInput` | Old global → `params.arguments` in `ontoolinput` handler |
|
||||
| `window.openai.toolOutput` | Old global → `params.structuredContent` in `ontoolresult` |
|
||||
| `window.openai` | Old global API → `App` instance methods |
|
||||
|
||||
- [ ] For each origin from your CSP investigation, show where it appears in the `registerAppResource()` CSP config. **Every** origin from the CSP investigation (universal, dev-only, prod-only) must be included in the CSP config—MCP Apps HTML runs in a sandboxed iframe **with no same-origin server**. If an origin was not included in the CSP config, add it now.
|
||||
|
||||
- [ ] For each conditional (dev-only, prod-only) origin from your CSP investigation, show the code where the same configuration setting (env var, config file, etc.) controls both the runtime URL and the CSP entry. If the CSP has a hardcoded origin that should be conditional, fix it now—the app must be production-ready.
|
||||
|
||||
## Testing
|
||||
|
||||
### Using basic-host
|
||||
|
||||
Test the migrated app with the basic-host example:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Build and run your server
|
||||
npm run build && npm run serve
|
||||
|
||||
# Terminal 2: Run basic-host (from cloned repo)
|
||||
cd /tmp/mcp-ext-apps/examples/basic-host
|
||||
npm install
|
||||
SERVERS='["http://localhost:3001/mcp"]' npm run start
|
||||
# Open http://localhost:8080
|
||||
```
|
||||
|
||||
### Verify Runtime Behavior
|
||||
|
||||
Once the app loads in basic-host, confirm:
|
||||
|
||||
1. App loads without console errors
|
||||
2. `ontoolinput` handler fires with tool arguments
|
||||
3. `ontoolresult` handler fires with tool result
|
||||
19
apps/mcp-app/.cursor-plugin/marketplace.json
Normal file
19
apps/mcp-app/.cursor-plugin/marketplace.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "tldraw-mcp",
|
||||
"owner": {
|
||||
"name": "tldraw",
|
||||
"email": "tim@tldraw.com"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Draw and visually collaborate with your agents inside Cursor",
|
||||
"version": "0.1.0",
|
||||
"pluginRoot": "apps/mcp-app/plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "tldraw-mcp",
|
||||
"source": "tldraw-mcp",
|
||||
"description": "Draw and visually collaborate with your agents inside Cursor"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
apps/mcp-app/.gitignore
vendored
Normal file
7
apps/mcp-app/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Local planning/reference docs
|
||||
MCP_APPS_GUIDE.md
|
||||
mcp-deployed-server-plan.md
|
||||
|
||||
# Environment secrets
|
||||
.env
|
||||
.dev.vars
|
||||
9
apps/mcp-app/LICENSE.md
Normal file
9
apps/mcp-app/LICENSE.md
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tldraw Inc.
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
141
apps/mcp-app/README.md
Normal file
141
apps/mcp-app/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# tldraw MCP app
|
||||
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
|
||||
The app has two parts: a **server** and a **widget**.
|
||||
|
||||
### Server
|
||||
|
||||
The server runs in Cloudflare Workers via `src/worker.ts`, using a Durable Object (`TldrawMCP`) backed by SQLite for persistent checkpoint storage.
|
||||
|
||||
It exposes:
|
||||
|
||||
- `search` — query the extracted Editor API spec in a sandboxed dynamic worker
|
||||
- `exec` — execute JavaScript against the live editor in the widget via a pending-request callback bridge
|
||||
- `_exec_callback` — app-only tool the widget calls to resolve a pending `exec` request
|
||||
- `save_checkpoint` / `read_checkpoint` — app-only tools used by the widget for checkpoint persistence
|
||||
|
||||
### Widget
|
||||
|
||||
The widget is a React app (`src/widget/mcp-app.tsx`) that renders a full tldraw canvas inside the MCP host's iframe.
|
||||
|
||||
When the AI calls `exec`, the server creates a pending request and the widget picks it up, runs the code through a focused editor proxy (`src/widget/focused/`) that translates between an AI-friendly shape format (simple string IDs, flat `_type` shapes) and tldraw's internal `TLShape`/`TLShapeId` types, then calls `_exec_callback` to resolve the pending request with the result. Canvas state is checkpointed to the Durable Object's SQLite database and to the browser's local storage.
|
||||
|
||||
## Developing
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The widget build depends on generated files (`editor-api.json`, `method-map.json`) that are extracted from the editor's TypeScript declarations. Before you can develop or build the mcp-app, you need to build the core packages first:
|
||||
|
||||
```bash
|
||||
# from the repo root
|
||||
yarn build
|
||||
```
|
||||
|
||||
This produces the `.tsbuild/` output that `yarn extract-api` reads from. The `build` and `dev` scripts run `extract-api` automatically, so you don't need to call it separately.
|
||||
|
||||
### Package scripts
|
||||
|
||||
Run all commands from `apps/mcp-app`.
|
||||
|
||||
| Command | What it does |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `yarn build` | Build the widget HTML |
|
||||
| `yarn dev` | Build widget + start local Cloudflare worker (HTTP MCP on `localhost:8787`) |
|
||||
| `yarn dev:tunnel` | Build widget + start a stable named Cloudflare tunnel + local worker with `WORKER_ORIGIN` set to the tunnel URL |
|
||||
| `yarn deploy` | Build widget + deploy the Cloudflare worker to production |
|
||||
|
||||
`yarn dev:tunnel` requires the `cloudflared` CLI to be installed on your machine and a one-time `cloudflared tunnel login`. It serves a stable per-user hostname (`<user>-mcp-app-dev.tldraw.xyz`) via a named tunnel, so the URL you register in hosted MCP clients stays the same across runs instead of changing every time. The user slug defaults to `whoami`; override it (or the zone/port) with `MCP_TUNNEL_USER`, `MCP_TUNNEL_ZONE`, and `PORT`. The zone must be a Cloudflare-hosted zone (`tldraw.xyz` is; `tldraw.dev`/`tldraw.com` are on Vercel and won't work).
|
||||
|
||||
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.
|
||||
|
||||
### Cursor setup
|
||||
|
||||
Add these two servers in `~/.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"tldraw": {
|
||||
"transport": "http",
|
||||
"url": "https://tldraw-mcp-app.tldraw.workers.dev/mcp"
|
||||
},
|
||||
"tldraw-local": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://127.0.0.1:8787/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop local setup
|
||||
|
||||
For local Claude Desktop development, use `claude_desktop_config.json` with the local HTTP server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"tldraw-local": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://127.0.0.1:8787/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop remote setup
|
||||
|
||||
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`.
|
||||
|
||||
1. Open Claude Desktop
|
||||
2. In the sidebar, go to **Customize**
|
||||
3. Open **Connectors**
|
||||
4. Click the button to add a connector, then choose **Add custom connector**
|
||||
5. Give it a name such as `tldraw`
|
||||
6. Paste `https://tldraw-mcp-app.tldraw.workers.dev/mcp` as the server URL
|
||||
|
||||
The **Add custom connector** option is not available on the free plan, so you may need Max or another paid plan.
|
||||
|
||||
If you need Notion access in Claude Desktop, use the Notion MCP connector for that separately.
|
||||
|
||||
### ChatGPT local dev
|
||||
|
||||
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.
|
||||
|
||||
First-time setup (once per machine):
|
||||
|
||||
1. Run `cloudflared tunnel login` and choose the `tldraw.xyz` zone
|
||||
|
||||
Then, each session:
|
||||
|
||||
1. Run `yarn dev:tunnel` in `apps/mcp-app`
|
||||
2. It serves your stable hostname, e.g. `https://<user>-mcp-app-dev.tldraw.xyz` (the first run also creates the named tunnel and its DNS route)
|
||||
3. In ChatGPT web (not the desktop app), go to **Apps** and add your app using that URL
|
||||
4. You can then test in both ChatGPT web and the desktop or mobile apps
|
||||
|
||||
Because the hostname is stable per user, you only add the app in ChatGPT once — subsequent `yarn dev:tunnel` runs reuse the same URL.
|
||||
|
||||
`dev:tunnel` automatically wires `WORKER_ORIGIN` to the stable tunnel URL and sets `MCP_IS_DEV=true` for the local worker.
|
||||
|
||||
### Iteration loop
|
||||
|
||||
1. Make code changes in `apps/mcp-app`
|
||||
2. Run the relevant script (`dev` or `dev:tunnel`)
|
||||
3. Disconnect and reconnect the MCP server in your client (or reload the page/app)
|
||||
4. When making widget changes, make sure to rebuild, either by running `yarn build` or rerunning any of the dev scripts.
|
||||
|
||||
Reconnecting the server after changes is the most reliable way to pick up new code, especially when the widget HTML changes.
|
||||
|
||||
## Session storage lifecycle
|
||||
|
||||
Each MCP session is a `TldrawMCP` Durable Object. It keeps up to `MAX_CHECKPOINTS` (50) canvas snapshots and destroys itself after `IDLE_TTL_MS` (7 days) without a checkpoint save — a schedule armed in `init()` and re-armed on every check.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
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).
|
||||
88
apps/mcp-app/dev-tunnel.sh
Executable file
88
apps/mcp-app/dev-tunnel.sh
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts a named cloudflared tunnel (stable per-user hostname) + wrangler dev,
|
||||
# wiring the stable tunnel hostname into the worker as WORKER_ORIGIN.
|
||||
#
|
||||
# Unlike a quick tunnel (random *.trycloudflare.com URL each run), the named
|
||||
# tunnel always serves at the same hostname, so you register the URL in
|
||||
# ChatGPT / other hosted MCP clients once and never touch it again.
|
||||
#
|
||||
# Usage: ./dev-tunnel.sh
|
||||
# Env overrides:
|
||||
# MCP_TUNNEL_USER - slug used in the tunnel name/hostname (default: whoami)
|
||||
# MCP_TUNNEL_ZONE - DNS zone for the hostname
|
||||
# PORT - local worker port (default: 8787)
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-8787}"
|
||||
USER_SLUG="${MCP_TUNNEL_USER:-$(whoami)}"
|
||||
ZONE="${MCP_TUNNEL_ZONE:-tldraw.xyz}"
|
||||
TUNNEL_NAME="${USER_SLUG}-mcp-app-dev"
|
||||
HOSTNAME="${TUNNEL_NAME}.${ZONE}"
|
||||
TUNNEL_URL="https://${HOSTNAME}"
|
||||
CRED_FILE="$HOME/.cloudflared/${TUNNEL_NAME}.json"
|
||||
|
||||
# 0. Require cloudflared and a one-time login
|
||||
if ! command -v cloudflared >/dev/null 2>&1; then
|
||||
echo "ERROR: cloudflared is not installed."
|
||||
echo "See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$HOME/.cloudflared/cert.pem" ]; then
|
||||
echo "You are not logged in to cloudflared. Run this once, then re-run this script:"
|
||||
echo " cloudflared tunnel login"
|
||||
echo "(choose the ${ZONE} zone when prompted)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Ensure the tunnel exists and we have local credentials for it.
|
||||
# If the tunnel already exists in the account but the local credentials file
|
||||
# is gone (e.g. you wiped ~/.cloudflared or switched machines), re-download
|
||||
# it with `tunnel token` instead of failing in `tunnel run`.
|
||||
if cloudflared tunnel info "$TUNNEL_NAME" >/dev/null 2>&1; then
|
||||
if [ ! -f "$CRED_FILE" ]; then
|
||||
echo "Restoring credentials for existing tunnel ${TUNNEL_NAME}..."
|
||||
cloudflared tunnel token --cred-file "$CRED_FILE" "$TUNNEL_NAME"
|
||||
fi
|
||||
else
|
||||
echo "Creating tunnel ${TUNNEL_NAME}..."
|
||||
cloudflared tunnel create --credentials-file "$CRED_FILE" "$TUNNEL_NAME"
|
||||
fi
|
||||
|
||||
# 2. Make sure the stable hostname routes to this tunnel. --overwrite-dns makes
|
||||
# the specific record win over any *.${ZONE} wildcard and re-points it if the
|
||||
# tunnel was ever recreated.
|
||||
echo "Ensuring DNS route ${HOSTNAME} -> ${TUNNEL_NAME}..."
|
||||
if ! ROUTE_OUT=$(cloudflared tunnel route dns --overwrite-dns "$TUNNEL_NAME" "$HOSTNAME" 2>&1); then
|
||||
echo "ERROR: failed to route ${HOSTNAME} to ${TUNNEL_NAME}:"
|
||||
echo "$ROUTE_OUT"
|
||||
echo "Make sure ${ZONE} is a Cloudflare-hosted zone in your account and you ran 'cloudflared tunnel login'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build widget first
|
||||
echo "Building widget..."
|
||||
yarn build:widget
|
||||
|
||||
# 3. Start cloudflared in the background, forwarding the tunnel to the local worker
|
||||
cloudflared tunnel --url "http://localhost:$PORT" run --cred-file "$CRED_FILE" "$TUNNEL_NAME" &
|
||||
CF_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping cloudflared (pid $CF_PID)..."
|
||||
kill "$CF_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo ""
|
||||
echo "==================================="
|
||||
echo "Stable tunnel URL: $TUNNEL_URL"
|
||||
echo "==================================="
|
||||
echo ""
|
||||
echo "Register this once in ChatGPT / claude_desktop_config.json:"
|
||||
echo " \"args\": [\"-y\", \"mcp-remote\", \"$TUNNEL_URL/mcp\"]"
|
||||
echo ""
|
||||
|
||||
# 4. Start wrangler with the stable tunnel URL as WORKER_ORIGIN in dev mode.
|
||||
# Runs in the foreground; the EXIT trap stops cloudflared when wrangler exits.
|
||||
wrangler dev --port "$PORT" --var "WORKER_ORIGIN:$TUNNEL_URL" --var "MCP_IS_DEV:true"
|
||||
43
apps/mcp-app/package.json
Normal file
43
apps/mcp-app/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "mcp-app",
|
||||
"version": "1.0.0",
|
||||
"mcpName": "io.github.tldraw/tldraw",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"extract-api": "tsx scripts/extract-editor-api.ts",
|
||||
"generate-agent-sdk-docs": "yarn run -T tsc --build ../../packages/editor/tsconfig.json ../../packages/store/tsconfig.json ../../packages/tlschema/tsconfig.json && yarn extract-api",
|
||||
"build:widget": "yarn generate-agent-sdk-docs && node ../../packages/tldraw/scripts/copy-css-files.mjs && vite build && mv dist/index.html dist/mcp-app.html",
|
||||
"build": "yarn build:widget",
|
||||
"dev": "yarn dev:http",
|
||||
"dev:http": "yarn build && wrangler dev --var \"MCP_IS_DEV:true\" --var \"WORKER_ORIGIN:http://localhost:8787\"",
|
||||
"dev:tunnel": "yarn build && bash dev-tunnel.sh",
|
||||
"deploy": "yarn build && wrangler deploy",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"agents": "^0.19.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tldraw": "workspace:*",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-singlefile": "^2.3.2",
|
||||
"vitest": "^5.0.0",
|
||||
"wrangler": "^4.75.0"
|
||||
}
|
||||
}
|
||||
10
apps/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json
Normal file
10
apps/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "tldraw",
|
||||
"displayName": "tldraw",
|
||||
"version": "0.1.0",
|
||||
"description": "Draw and visually collaborate with your agents inside Cursor",
|
||||
"author": {
|
||||
"name": "tldraw"
|
||||
},
|
||||
"logo": "assets/logo.svg"
|
||||
}
|
||||
3
apps/mcp-app/plugins/tldraw-mcp/assets/logo.svg
Normal file
3
apps/mcp-app/plugins/tldraw-mcp/assets/logo.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="1001" height="1001" viewBox="0 0 1001 1001" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.745605 130.5C0.745605 58.5 58.7456 0.5 130.746 0.5H870.746C942.746 0.5 1000.75 58.5 1000.75 130.5V870.5C1000.75 942.5 942.746 1000.5 870.746 1000.5H130.746C58.7456 1000.5 0.745605 942.5 0.745605 870.5V130.5ZM590.746 300.5C590.746 350.5 550.746 390.5 500.746 390.5C450.746 390.5 410.746 350.5 410.746 300.5C410.746 250.5 450.746 210.5 500.746 210.5C550.746 210.5 590.746 250.5 590.746 300.5ZM470.746 810.5C513.746 810.5 554.746 751.5 569.746 719.5C588.746 678.5 599.746 605.5 580.746 562.5C567.746 532.5 537.746 510.5 497.746 510.5C449.746 510.5 410.746 549.5 410.746 597.5C410.746 640.5 441.746 674.5 481.746 681.5C483.746 681.5 485.746 684.5 485.746 686.5C481.746 711.5 470.746 743.5 453.746 759.5C432.746 779.5 438.746 810.5 470.746 810.5Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 956 B |
8
apps/mcp-app/plugins/tldraw-mcp/mcp.json
Normal file
8
apps/mcp-app/plugins/tldraw-mcp/mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tldraw": {
|
||||
"transport": "http",
|
||||
"url": "https://tldraw-mcp-app.tldraw.workers.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
1374
apps/mcp-app/scripts/extract-editor-api.ts
Normal file
1374
apps/mcp-app/scripts/extract-editor-api.ts
Normal file
File diff suppressed because it is too large
Load Diff
21
apps/mcp-app/server.json
Normal file
21
apps/mcp-app/server.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.tldraw/tldraw",
|
||||
"description": "Draw and visually collaborate with your agents on tldraw's canvas.",
|
||||
"repository": {
|
||||
"url": "https://github.com/tldraw/tldraw",
|
||||
"source": "github",
|
||||
"subfolder": "apps/mcp-app"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": "https://tldraw-mcp-app.tldraw.workers.dev/mcp"
|
||||
},
|
||||
{
|
||||
"type": "sse",
|
||||
"url": "https://tldraw-mcp-app.tldraw.workers.dev/sse"
|
||||
}
|
||||
]
|
||||
}
|
||||
78
apps/mcp-app/src/agents-canary.test.ts
Normal file
78
apps/mcp-app/src/agents-canary.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// destroyIfIdle/expireIfIdle in worker.ts lean on these agents-SDK internals.
|
||||
// A bump that moves any of them must re-audit that code before shipping;
|
||||
// this file turns that into a failing test instead of a silent prod regression.
|
||||
const require = createRequire(import.meta.url)
|
||||
const agentsDist = readFileSync(require.resolve('agents'), 'utf8')
|
||||
|
||||
function between(src: string, startMarker: string, endMarker: string): string {
|
||||
const start = src.indexOf(startMarker)
|
||||
if (start === -1) throw new Error(`marker not found: ${startMarker}`)
|
||||
const end = src.indexOf(endMarker, start)
|
||||
if (end === -1) throw new Error(`end marker not found after ${startMarker}: ${endMarker}`)
|
||||
return src.slice(start, end)
|
||||
}
|
||||
|
||||
describe('agents SDK assumptions behind session idle expiry', () => {
|
||||
it('schedule() gates its callback+payload dedup query behind the idempotent option', () => {
|
||||
// schedule() itself delegates to _insertScheduleForOwner, which is where the
|
||||
// idempotent check and its dedup query actually live.
|
||||
const body = between(
|
||||
agentsDist,
|
||||
'async _insertScheduleForOwner(ownerPath, when, callback, payload, options) {',
|
||||
'async _cf_scheduleForFacet('
|
||||
)
|
||||
const idempotentAt = body.indexOf('options?.idempotent')
|
||||
const dedupAt = body.indexOf('payload IS ${')
|
||||
expect(idempotentAt).toBeGreaterThan(-1)
|
||||
expect(dedupAt).toBeGreaterThan(idempotentAt)
|
||||
})
|
||||
|
||||
it('the alarm body runs a schedule callback BEFORE deleting its row (so expireIfIdle must not dedup its re-arm)', () => {
|
||||
const alarmBody = between(
|
||||
agentsDist,
|
||||
'async _cf_runAlarmBody(',
|
||||
'await this._scheduleNextAlarm();'
|
||||
)
|
||||
const callbackAt = alarmBody.indexOf('await this._executeScheduleCallback(row)')
|
||||
const deleteAt = alarmBody.indexOf('DELETE FROM cf_agents_schedules WHERE id = ${row.id}')
|
||||
expect(callbackAt).toBeGreaterThan(-1)
|
||||
expect(deleteAt).toBeGreaterThan(callbackAt)
|
||||
})
|
||||
|
||||
it('_scheduleNextAlarmBody short-circuits to setAlarm(now) when the destroy marker is set (so the condemn setAlarm cannot be clobbered)', () => {
|
||||
const body = between(agentsDist, 'async _scheduleNextAlarmBody() {', 'const nowMs = Date.now()')
|
||||
expect(body).toContain('_hasPendingDestroy()')
|
||||
// setAlarm(Date.now()) can equal the running alarm's own time and get
|
||||
// dropped; TldrawMCP.alarm() moves it. If the SDK starts adding its
|
||||
// DESTROY_ALARM_DELAY_MS here, that override can go.
|
||||
expect(body).toContain('setAlarm(Date.now())')
|
||||
})
|
||||
|
||||
it('_destroyed short-circuits the alarm body before the post-callback row delete', () => {
|
||||
const alarmBody = between(
|
||||
agentsDist,
|
||||
'async _cf_runAlarmBody(',
|
||||
'await this._scheduleNextAlarm();'
|
||||
)
|
||||
const callbackAt = alarmBody.indexOf('await this._executeScheduleCallback(row)')
|
||||
const guardAt = alarmBody.indexOf('if (this._destroyed) return;', callbackAt)
|
||||
const deleteAt = alarmBody.indexOf('DELETE FROM cf_agents_schedules WHERE id = ${row.id}')
|
||||
expect(guardAt).toBeGreaterThan(callbackAt)
|
||||
expect(guardAt).toBeLessThan(deleteAt)
|
||||
})
|
||||
|
||||
it('the destroy marker key is cf_agents_destroy_pending and Agent.alarm() checks it before anything else, without calling super.alarm()', () => {
|
||||
expect(agentsDist).toContain('DESTROY_PENDING_KEY = "cf_agents_destroy_pending"')
|
||||
const alarm = between(agentsDist, '\tasync alarm() {', '\tasync _cf_runAlarmBody(')
|
||||
// First statement is the marker check; partyserver's alarm() (which runs
|
||||
// onStart/init) must never be reached on the condemned path.
|
||||
expect(
|
||||
alarm.trimStart().startsWith('async alarm() {\n\t\tif (await this._hasPendingDestroy())')
|
||||
).toBe(true)
|
||||
expect(alarm).not.toContain('super.alarm(')
|
||||
})
|
||||
})
|
||||
103
apps/mcp-app/src/canvas-store.ts
Normal file
103
apps/mcp-app/src/canvas-store.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
|
||||
/**
|
||||
* Widget↔server exec-result rendezvous.
|
||||
*
|
||||
* MCP hosts do not give us a stable session: Claude routes widget-initiated
|
||||
* calls over a different MCP session than model-initiated ones, so a widget's
|
||||
* `_exec_callback` can land on a different McpAgent DO than the one holding
|
||||
* the pending exec promise. The handoff rendezvouses here instead, addressed
|
||||
* by `idFromName('exec:<execKey>')` where execKey is a hash of the exec code —
|
||||
* the one value both the server (tool args) and the widget (the code it runs)
|
||||
* can derive independently.
|
||||
*
|
||||
* `putExecResult` resolves an in-memory waiter when one is registered and
|
||||
* stashes to storage otherwise, so the handoff also survives the callback
|
||||
* arriving before exec starts waiting.
|
||||
*/
|
||||
|
||||
const EXEC_RESULT_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
interface ExecWaiter {
|
||||
resolve(payload: string | null): void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export class CanvasStore extends DurableObject<unknown> {
|
||||
private waiters = new Map<string, ExecWaiter>()
|
||||
|
||||
constructor(ctx: DurableObjectState, env: unknown) {
|
||||
super(ctx, env)
|
||||
this.ctx.storage.sql.exec(
|
||||
`CREATE TABLE IF NOT EXISTS exec_results (key TEXT PRIMARY KEY, payload TEXT, created_at INTEGER)`
|
||||
)
|
||||
}
|
||||
|
||||
/** Deliver a result to a waiting exec call, or stash it if none is waiting yet. */
|
||||
putExecResult(key: string, payloadJson: string): { delivered: boolean } {
|
||||
const waiter = this.waiters.get(key)
|
||||
if (waiter) {
|
||||
clearTimeout(waiter.timer)
|
||||
this.waiters.delete(key)
|
||||
waiter.resolve(payloadJson)
|
||||
return { delivered: true }
|
||||
}
|
||||
this.ctx.storage.sql.exec(
|
||||
`INSERT INTO exec_results (key, payload, created_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET payload = excluded.payload, created_at = excluded.created_at`,
|
||||
key,
|
||||
payloadJson,
|
||||
Date.now()
|
||||
)
|
||||
void this.ctx.storage.setAlarm(Date.now() + EXEC_RESULT_TTL_MS)
|
||||
return { delivered: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a result: consume a stashed one immediately, else block up to timeoutMs.
|
||||
*
|
||||
* `notBefore` is the caller's start time. A stashed result produced before the
|
||||
* caller started belongs to an earlier invocation that shared this execKey (a
|
||||
* retry, or a late callback from a run that already timed out), so it is
|
||||
* discarded rather than returned — otherwise this caller would report another
|
||||
* run's success, failure, or return value as its own.
|
||||
*/
|
||||
async waitExecResult(key: string, timeoutMs: number, notBefore = 0): Promise<string | null> {
|
||||
const rows = this.ctx.storage.sql
|
||||
.exec(`SELECT payload, created_at FROM exec_results WHERE key = ?`, key)
|
||||
.toArray()
|
||||
if (rows.length > 0) {
|
||||
// Consume the stash either way (single-use); only return it if it's fresh.
|
||||
this.ctx.storage.sql.exec(`DELETE FROM exec_results WHERE key = ?`, key)
|
||||
if ((rows[0].created_at as number) >= notBefore) {
|
||||
return rows[0].payload as string
|
||||
}
|
||||
}
|
||||
|
||||
// A superseded waiter (e.g. a retried exec) resolves empty so only the
|
||||
// newest caller receives the result.
|
||||
const existing = this.waiters.get(key)
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer)
|
||||
this.waiters.delete(key)
|
||||
existing.resolve(null)
|
||||
}
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.waiters.delete(key)
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
this.waiters.set(key, { resolve, timer })
|
||||
})
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
this.ctx.storage.sql.exec(
|
||||
`DELETE FROM exec_results WHERE created_at < ?`,
|
||||
Date.now() - EXEC_RESULT_TTL_MS
|
||||
)
|
||||
}
|
||||
}
|
||||
123
apps/mcp-app/src/idle-expiry.test.ts
Normal file
123
apps/mcp-app/src/idle-expiry.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Idle expiry against the real worker under wrangler dev.
|
||||
*
|
||||
* The one assertion that matters most: after the expiry alarm fires on a kept
|
||||
* session, exactly one `expireIfIdle` schedule row remains, with a new id. The
|
||||
* SDK deletes the executing row after the callback returns, so a re-arm that
|
||||
* dedups would vanish with it and the DO would never expire. This test checks
|
||||
* the outcome — our callback leaves a replacement row behind — while
|
||||
* agents-canary.test.ts checks the SDK ordering that outcome depends on.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||
import {
|
||||
MINIMAL_FIXTURES,
|
||||
initSession,
|
||||
mcpPost,
|
||||
startWranglerDev,
|
||||
type WranglerDevHandle,
|
||||
} from './wrangler-dev-harness'
|
||||
|
||||
// Short enough that the expiry alarm fires inside the test; the re-arm floor
|
||||
// (60s) still pushes the next one out, so we only assert the row survived.
|
||||
const IDLE_TTL_MS = 3000
|
||||
|
||||
// Alarm delivery under wrangler dev is not tied to the schedule time: on a
|
||||
// loaded runner the condemn + destroy chain has landed 8s past the TTL (#10709).
|
||||
const ALARM_DEADLINE_MS = 20_000
|
||||
|
||||
async function waitFor<T>(probe: () => Promise<T | undefined>, what: string): Promise<T> {
|
||||
const deadline = Date.now() + ALARM_DEADLINE_MS
|
||||
for (;;) {
|
||||
const value = await probe()
|
||||
if (value !== undefined) return value
|
||||
if (Date.now() > deadline) throw new Error(`timed out after ${ALARM_DEADLINE_MS}ms: ${what}`)
|
||||
// 1s keeps a full deadline of polls under the worker's 30 req/min
|
||||
// per-session rate limit.
|
||||
await new Promise((res) => setTimeout(res, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
describe('session DO idle expiry', () => {
|
||||
const port = 8700 + (process.pid % 500)
|
||||
let server: WranglerDevHandle | null = null
|
||||
const base = () => server!.base
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startWranglerDev({
|
||||
port,
|
||||
vars: { IDLE_TTL_MS_OVERRIDE: String(IDLE_TTL_MS) },
|
||||
fixtures: MINIMAL_FIXTURES,
|
||||
})
|
||||
}, 90_000)
|
||||
|
||||
afterAll(() => server?.stop())
|
||||
|
||||
async function schedules(sessionId: string): Promise<Array<{ id: string; time: number }>> {
|
||||
const res = await fetch(`${base()}/admin/schedules?session=${sessionId}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function saveCheckpoint(sessionId: string) {
|
||||
const res = await mcpPost(
|
||||
base(),
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'save_checkpoint',
|
||||
arguments: {
|
||||
checkpointId: 'cp-1',
|
||||
shapesJson: JSON.stringify([{ id: 'shape:a', type: 'geo', x: 0, y: 0 }]),
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionId
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
await res.text()
|
||||
}
|
||||
|
||||
test('exactly one expiry row after init, and still one after saves', async () => {
|
||||
const sessionId = await initSession(base(), 'idle-expiry')
|
||||
expect(await schedules(sessionId)).toHaveLength(1)
|
||||
await saveCheckpoint(sessionId)
|
||||
await saveCheckpoint(sessionId)
|
||||
expect(await schedules(sessionId)).toHaveLength(1)
|
||||
}, 30_000)
|
||||
|
||||
test('the re-arm survives the first alarm fire (new row, same count)', async () => {
|
||||
const sessionId = await initSession(base(), 'idle-expiry')
|
||||
await saveCheckpoint(sessionId)
|
||||
const [first] = await schedules(sessionId)
|
||||
expect(first).toBeDefined()
|
||||
|
||||
// the TTL alarm fires during this wait; the session was active seconds ago
|
||||
// so expireIfIdle keeps it and must re-arm. The old row is deleted only after
|
||||
// the callback returns, so both rows are briefly visible: wait for exactly one.
|
||||
const [replacement] = await waitFor(async () => {
|
||||
const current = await schedules(sessionId)
|
||||
return current.length === 1 && current[0].id !== first.id ? current : undefined
|
||||
}, 'expiry row replaced after the first alarm fire')
|
||||
expect(replacement.time).toBeGreaterThan(first.time)
|
||||
}, 30_000)
|
||||
|
||||
test('a session that never saves is destroyed by its own expiry alarm', async () => {
|
||||
// No checkpoint means no lastActivity, which reads as maximally idle: the
|
||||
// first alarm condemns it, and the subsequent destroy alarm wipes it.
|
||||
const sessionId = await initSession(base(), 'idle-expiry')
|
||||
const status = await waitFor(async () => {
|
||||
const res = await mcpPost(
|
||||
base(),
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} },
|
||||
sessionId
|
||||
)
|
||||
await res.text()
|
||||
// 429 is the rate limiter, not the DO: keep waiting
|
||||
return res.status === 200 || res.status === 429 ? undefined : res.status
|
||||
}, 'session destroyed after the expiry alarm')
|
||||
expect(status).toBe(404)
|
||||
}, 30_000)
|
||||
})
|
||||
95
apps/mcp-app/src/large-payload-smoke.test.ts
Normal file
95
apps/mcp-app/src/large-payload-smoke.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Guards against the Aug 2026 mcp-app outage: the agents SDK's default
|
||||
* DurableObjectEventStore persists every outgoing MCP message to DO storage before
|
||||
* writing it to the wire, and SQLite-backed DO storage caps a value at 2MB. Our
|
||||
* ~2.4MB widget resource exceeded that, the storage put threw, the rejection was
|
||||
* swallowed, and `resources/read` hung forever — the widget never loaded in any
|
||||
* host. `TldrawMCP.getEventStore()` returning undefined disables the store (the
|
||||
* transport optional-chains it), so large messages flow straight to the stream.
|
||||
*
|
||||
* Two layers:
|
||||
* - canaries: pin the SDK behaviors the override relies on, so an `agents` bump
|
||||
* that changes them fails here instead of silently re-breaking prod
|
||||
* - smoke: boot the real worker under wrangler and read a >2MB resource through
|
||||
* the actual transport, the request that hung in production
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||
import {
|
||||
MINIMAL_FIXTURES,
|
||||
initSession,
|
||||
mcpPost,
|
||||
parseSSEResult,
|
||||
startWranglerDev,
|
||||
type WranglerDevHandle,
|
||||
} from './wrangler-dev-harness'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
describe('agents SDK assumptions behind the getEventStore() override', () => {
|
||||
const mcpDist = readFileSync(require.resolve('agents/mcp'), 'utf8')
|
||||
|
||||
test('the transport optional-chains the event store, so disabling it still sends', () => {
|
||||
// `await this._eventStore?.storeEvent(...)` — with no store, the message goes
|
||||
// straight to the wire. If the SDK ever makes the store mandatory, the
|
||||
// override stops being a fix and this line changes shape.
|
||||
expect(mcpDist.match(/await this\._eventStore\?\.storeEvent\(/g)?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('the SSE keepalive clears itself when a write rejects (the #10010 leak fix)', () => {
|
||||
// First shipped in agents 0.14.0. A downgrade below that re-introduces the
|
||||
// leaked 30s interval that pinned abandoned sessions' durable objects awake.
|
||||
expect(mcpDist).toContain('.catch(() => clearInterval')
|
||||
})
|
||||
|
||||
test('TldrawMCP disables the event store', () => {
|
||||
// worker.ts imports cloudflare:workers transitively so it cannot be imported
|
||||
// here; pin the override at the source level instead.
|
||||
const workerSrc = readFileSync(join(__dirname, 'worker.ts'), 'utf8')
|
||||
expect(workerSrc).toMatch(/override getEventStore\(\)\s*\{\s*return undefined\s*\}/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('large resource reads through the real transport', () => {
|
||||
const port = 8100 + (process.pid % 500)
|
||||
let server: WranglerDevHandle | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startWranglerDev({
|
||||
port,
|
||||
fixtures: {
|
||||
// sized to reproduce the failing payload exactly
|
||||
'mcp-app.html': '<!doctype html><html><body>' + 'x'.repeat(2_600_000),
|
||||
'method-map.json': MINIMAL_FIXTURES['method-map.json'],
|
||||
},
|
||||
})
|
||||
}, 90_000)
|
||||
|
||||
afterAll(() => server?.stop())
|
||||
|
||||
test('resources/read of the >2MB widget completes instead of hanging', async () => {
|
||||
const base = server!.base
|
||||
const sessionId = await initSession(base, 'large-payload-smoke')
|
||||
|
||||
const toolsRes = await mcpPost(base, { jsonrpc: '2.0', id: 2, method: 'tools/list' }, sessionId)
|
||||
const tools = parseSSEResult(await toolsRes.text())
|
||||
expect(tools.result.tools.map((t: { name: string }) => t.name)).toContain('exec')
|
||||
|
||||
const readRes = await mcpPost(
|
||||
base,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'resources/read',
|
||||
params: { uri: 'ui://show-canvas/mcp-app.html' },
|
||||
},
|
||||
sessionId
|
||||
)
|
||||
expect(readRes.status).toBe(200)
|
||||
const read = parseSSEResult(await readRes.text())
|
||||
expect(read.error).toBeUndefined()
|
||||
expect(read.result.contents[0].text.length).toBeGreaterThan(2 * 1024 * 1024)
|
||||
}, 30_000)
|
||||
})
|
||||
45
apps/mcp-app/src/logger.ts
Normal file
45
apps/mcp-app/src/logger.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Structured logger for the tldraw MCP Durable Object.
|
||||
*
|
||||
* Outputs JSON lines to console.error so that
|
||||
* Cloudflare Workers Observability can capture and query them.
|
||||
*/
|
||||
|
||||
export class Logger {
|
||||
constructor(
|
||||
private prefix: string,
|
||||
private enabled: boolean
|
||||
) {}
|
||||
|
||||
info(message: string, data?: Record<string, unknown>) {
|
||||
if (!this.enabled) return
|
||||
console.error(
|
||||
JSON.stringify({ level: 'info', prefix: this.prefix, message, ...data, ts: Date.now() })
|
||||
)
|
||||
}
|
||||
|
||||
error(message: string, data?: Record<string, unknown>) {
|
||||
if (!this.enabled) return
|
||||
console.error(
|
||||
JSON.stringify({ level: 'error', prefix: this.prefix, message, ...data, ts: Date.now() })
|
||||
)
|
||||
}
|
||||
|
||||
debug(message: string, data?: Record<string, unknown>) {
|
||||
if (!this.enabled) return
|
||||
console.error(
|
||||
JSON.stringify({ level: 'debug', prefix: this.prefix, message, ...data, ts: Date.now() })
|
||||
)
|
||||
}
|
||||
|
||||
child(prefix: string): Logger {
|
||||
return new Logger(`${this.prefix}:${prefix}`, this.enabled)
|
||||
}
|
||||
|
||||
/** Returns a log function compatible with RegisterToolsOptions.log */
|
||||
toLogFn(): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
this.info(args.map(String).join(' '))
|
||||
}
|
||||
}
|
||||
}
|
||||
38
apps/mcp-app/src/next-expiry-time.test.ts
Normal file
38
apps/mcp-app/src/next-expiry-time.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { IDLE_TTL_MS, nextExpiryTime } from './shared/types'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
const HOUR = 60 * 60_000
|
||||
|
||||
describe('nextExpiryTime', () => {
|
||||
it('backs off an hour when the check failed, even with no lastActivity to go on', () => {
|
||||
// The regression: `lastActivity ?? now` plus a 7-day TTL beat the hour
|
||||
// floor, so a storage error pushed the retry a full TTL out.
|
||||
expect(nextExpiryTime({ failed: true, lastActivity: null, now: NOW, ttlMs: IDLE_TTL_MS })).toBe(
|
||||
NOW + HOUR
|
||||
)
|
||||
})
|
||||
|
||||
it('backs off an hour when the check failed but lastActivity is known', () => {
|
||||
expect(
|
||||
nextExpiryTime({ failed: true, lastActivity: NOW - 1000, now: NOW, ttlMs: IDLE_TTL_MS })
|
||||
).toBe(NOW + HOUR)
|
||||
})
|
||||
|
||||
it('schedules a full TTL past the last activity on a healthy check', () => {
|
||||
expect(
|
||||
nextExpiryTime({ failed: false, lastActivity: NOW - 1000, now: NOW, ttlMs: IDLE_TTL_MS })
|
||||
).toBe(NOW - 1000 + IDLE_TTL_MS)
|
||||
})
|
||||
|
||||
it('applies the 60s floor when lastActivity + ttl is already in the past', () => {
|
||||
expect(
|
||||
nextExpiryTime({
|
||||
failed: false,
|
||||
lastActivity: NOW - 2 * IDLE_TTL_MS,
|
||||
now: NOW,
|
||||
ttlMs: IDLE_TTL_MS,
|
||||
})
|
||||
).toBe(NOW + 60_000)
|
||||
})
|
||||
})
|
||||
404
apps/mcp-app/src/register-tools.ts
Normal file
404
apps/mcp-app/src/register-tools.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'
|
||||
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import { CANVAS_RESOURCE_URI } from './shared/types'
|
||||
import type { RegisterToolsOptions, ServerDeps } from './shared/types'
|
||||
import { errorResponse, parseTlShapes } from './shared/utils'
|
||||
import { registerExecTool } from './tools/exec'
|
||||
import { registerSearchTool } from './tools/search'
|
||||
|
||||
/**
|
||||
* Shared tool/resource registration logic for the MCP worker runtime.
|
||||
*
|
||||
* Tools:
|
||||
* - search: Query the Editor API spec (server-side)
|
||||
* - exec: Execute JS against the live editor in the widget
|
||||
* - read_checkpoint: Read checkpoint data (app-only)
|
||||
* - save_checkpoint: Save checkpoint data (app-only)
|
||||
* - tldraw-canvas: Interactive canvas widget resource
|
||||
*/
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function injectBootstrapData(html: string, bootstrap: Record<string, unknown>): string {
|
||||
const toBase64 =
|
||||
typeof Buffer !== 'undefined' ? (s: string) => Buffer.from(s).toString('base64') : btoa
|
||||
const encoded = toBase64(JSON.stringify(bootstrap))
|
||||
const bootstrapScript = `<script>window.__TLDRAW_BOOTSTRAP__=JSON.parse(atob("${encoded}"))</script>`
|
||||
const lastIdx = html.lastIndexOf('</head>')
|
||||
if (lastIdx === -1) return html
|
||||
return html.slice(0, lastIdx) + bootstrapScript + html.slice(lastIdx)
|
||||
}
|
||||
|
||||
function parseArrayJson(json: string, fieldName: string): unknown[] {
|
||||
const parsed = JSON.parse(json)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`${fieldName} must be a JSON array string. Build an array first, then pass JSON.stringify(array).`
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function getWidgetDomain(
|
||||
hostName: string | undefined,
|
||||
isDev: boolean,
|
||||
workerOrigin?: string
|
||||
): Promise<string | undefined> {
|
||||
if (isDev) return undefined
|
||||
if (hostName === 'chatgpt') return 'https://tldraw.com'
|
||||
if (hostName === 'claude' && workerOrigin) {
|
||||
const mcpUrl = new URL('/mcp', workerOrigin).toString()
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(mcpUrl))
|
||||
const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 32)
|
||||
return `${hash}.claudemcpcontent.com`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
export function registerTools(
|
||||
server: McpServer,
|
||||
deps: ServerDeps,
|
||||
opts: RegisterToolsOptions
|
||||
): void {
|
||||
const log = opts.log ?? ((...args: unknown[]) => console.error(...args))
|
||||
const analytics = opts.analytics
|
||||
|
||||
// --- search (server-side spec query) ---
|
||||
|
||||
registerSearchTool(server, {
|
||||
analytics,
|
||||
log,
|
||||
loader: opts.searchWorkerLoader,
|
||||
loadSpec: deps.loadEditorApiSpec,
|
||||
})
|
||||
|
||||
// --- exec (client-side code execution) ---
|
||||
|
||||
registerExecTool(server, {
|
||||
analytics,
|
||||
log,
|
||||
pendingRequests: opts.pendingRequests,
|
||||
getMcpSessionId: () => deps.getMcpSessionId(),
|
||||
getClientHostName: () => opts.getClientHostName(),
|
||||
waitExecResult: (execKey, timeoutMs, notBefore) =>
|
||||
deps.waitExecResult(execKey, timeoutMs, notBefore),
|
||||
})
|
||||
|
||||
// --- _exec_callback (app-only: widget resolves pending exec via callServerTool) ---
|
||||
|
||||
const execCallbackSchema = z.object({
|
||||
channel: z.string(),
|
||||
execKey: z.string().optional(),
|
||||
result: z
|
||||
.object({
|
||||
success: z.boolean(),
|
||||
result: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
canvasId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
server.registerTool(
|
||||
'_exec_callback',
|
||||
{
|
||||
title: 'Exec Callback',
|
||||
description: 'App-only: widget calls this to resolve a pending exec request.',
|
||||
inputSchema: execCallbackSchema,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
_meta: { ui: { visibility: ['app'] } },
|
||||
},
|
||||
async ({
|
||||
channel,
|
||||
execKey,
|
||||
result,
|
||||
error,
|
||||
}: z.infer<typeof execCallbackSchema>): Promise<CallToolResult> => {
|
||||
log(
|
||||
`[tldraw-mcp] _exec_callback received: channel=${channel}, execKey=${execKey ?? 'none'}, isError=${Boolean(error)}, mcpSessionId=${deps.getMcpSessionId()}, receivedAt=${Date.now()}`
|
||||
)
|
||||
const handled = error
|
||||
? opts.pendingRequests.reject(channel, error)
|
||||
: opts.pendingRequests.resolve(channel, result)
|
||||
|
||||
if (!handled) {
|
||||
// No pending exec on this DO — the host routed the callback over a
|
||||
// different session than the exec call. Forward the result to the
|
||||
// exec:<execKey> rendezvous DO for the waiting exec to pick up.
|
||||
const payload = error ? { success: false, error } : (result ?? null)
|
||||
let forwarded = false
|
||||
let delivered = false
|
||||
if (execKey && payload) {
|
||||
delivered = await deps.putExecResult(execKey, payload)
|
||||
forwarded = true
|
||||
}
|
||||
log(
|
||||
`[tldraw-mcp] exec callback for non-pending channel "${channel}": ${forwarded ? `forwarded to rendezvous execKey=${execKey} (delivered=${delivered})` : 'no execKey, dropped'}`
|
||||
)
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: forwarded }) }],
|
||||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] }
|
||||
}
|
||||
)
|
||||
|
||||
// --- _get_canvas_state (app-only: widget fetches fork data by canvasId) ---
|
||||
|
||||
server.registerTool(
|
||||
'_get_canvas_state',
|
||||
{
|
||||
title: 'Get Canvas State',
|
||||
description: 'App-only: get the latest checkpoint for a canvas by its canvasId.',
|
||||
inputSchema: z.object({ canvasId: z.string().min(1) }),
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
_meta: { ui: { visibility: ['app'] } },
|
||||
},
|
||||
async ({ canvasId }: { canvasId: string }): Promise<CallToolResult> => {
|
||||
const checkpointId = deps.getCanvasCheckpointId(canvasId)
|
||||
if (!checkpointId) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) },
|
||||
],
|
||||
}
|
||||
}
|
||||
const checkpoint = deps.loadCheckpoint(checkpointId)
|
||||
if (!checkpoint) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) },
|
||||
],
|
||||
}
|
||||
}
|
||||
const shapes = parseTlShapes(checkpoint.shapes)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
checkpointId,
|
||||
shapes,
|
||||
assets: checkpoint.assets,
|
||||
bindings: checkpoint.bindings,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// --- read_checkpoint (app-only) ---
|
||||
|
||||
server.registerTool(
|
||||
'read_checkpoint',
|
||||
{
|
||||
title: 'Read Checkpoint',
|
||||
description: 'App-only: read shapes from a checkpoint by ID.',
|
||||
inputSchema: z.object({ checkpointId: z.string().min(1) }),
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
_meta: { ui: { visibility: ['app'] } },
|
||||
},
|
||||
async ({ checkpointId }: { checkpointId: string }): Promise<CallToolResult> => {
|
||||
const checkpoint = deps.loadCheckpoint(checkpointId)
|
||||
if (!checkpoint) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Not found.' }],
|
||||
structuredContent: {
|
||||
sessionId: deps.getSessionId(),
|
||||
shapes: [],
|
||||
assets: [],
|
||||
bindings: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const shapes = parseTlShapes(checkpoint.shapes)
|
||||
const assets = checkpoint.assets
|
||||
const bindings = checkpoint.bindings
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `${shapes.length} shape(s), ${assets.length} asset(s).` }],
|
||||
structuredContent: {
|
||||
sessionId: deps.getSessionId(),
|
||||
shapes,
|
||||
assets,
|
||||
bindings,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// --- save_checkpoint (app-only) ---
|
||||
|
||||
server.registerTool(
|
||||
'save_checkpoint',
|
||||
{
|
||||
title: 'Save Checkpoint',
|
||||
description:
|
||||
'App-only: save shapes to a checkpoint (from user edits). shapesJson and assetsJson must be JSON array strings.',
|
||||
inputSchema: z.object({
|
||||
checkpointId: z.string().min(1),
|
||||
shapesJson: z.string(),
|
||||
assetsJson: z.string().optional(),
|
||||
bindingsJson: z.string().optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
_meta: { ui: { visibility: ['app'] } },
|
||||
},
|
||||
async ({
|
||||
checkpointId,
|
||||
shapesJson,
|
||||
assetsJson,
|
||||
bindingsJson,
|
||||
canvasId,
|
||||
}: {
|
||||
checkpointId: string
|
||||
shapesJson: string
|
||||
assetsJson?: string
|
||||
bindingsJson?: string
|
||||
canvasId?: string
|
||||
}): Promise<CallToolResult> => {
|
||||
try {
|
||||
log(
|
||||
`[tldraw-mcp] save_checkpoint called: checkpointId=${checkpointId}, canvasId=${canvasId ?? 'none'}, prev activeCheckpointId=${deps.getActiveCheckpointId()}`
|
||||
)
|
||||
const raw = parseArrayJson(shapesJson, 'shapesJson')
|
||||
const shapes = parseTlShapes(raw)
|
||||
const assets = assetsJson ? parseArrayJson(assetsJson, 'assetsJson') : []
|
||||
const bindings = bindingsJson ? parseArrayJson(bindingsJson, 'bindingsJson') : []
|
||||
deps.saveCheckpoint(checkpointId, shapes, assets, bindings)
|
||||
deps.setActiveCheckpointId(checkpointId)
|
||||
if (canvasId) {
|
||||
deps.setCanvasCheckpointId(canvasId, checkpointId)
|
||||
}
|
||||
log(
|
||||
`[tldraw-mcp] save_checkpoint done: activeCheckpointId=${deps.getActiveCheckpointId()}, canvasId=${canvasId ?? 'none'}, shapes=${shapes.length}, assets=${assets.length}`
|
||||
)
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Saved ${shapes.length} shape(s), ${assets.length} asset(s).` },
|
||||
],
|
||||
structuredContent: {
|
||||
checkpointId,
|
||||
sessionId: deps.getSessionId(),
|
||||
shapesCount: shapes.length,
|
||||
assetsCount: assets.length,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return errorResponse('save_checkpoint', err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// --- canvas resource ---
|
||||
|
||||
const readCanvasResource = async (uri: string): Promise<ReadResourceResult> => {
|
||||
analytics?.writeDataPoint({
|
||||
blobs: ['resource_called', 'tldraw-canvas'],
|
||||
})
|
||||
let html = await deps.loadWidgetHtml()
|
||||
|
||||
const sid = deps.getSessionId()
|
||||
const hostName = opts.getClientHostName()
|
||||
|
||||
const bootstrap: Record<string, unknown> = {
|
||||
sessionId: sid,
|
||||
isDev: opts.isDev,
|
||||
workerOrigin: opts.workerOrigin,
|
||||
mcpSessionId: deps.getMcpSessionId(),
|
||||
methodMap: await deps.loadMethodMap(),
|
||||
}
|
||||
|
||||
html = injectBootstrapData(html, bootstrap)
|
||||
|
||||
const domain = await getWidgetDomain(hostName, opts.isDev, opts.workerOrigin)
|
||||
|
||||
log(`[tldraw-mcp] Serving resource "${uri}" to "${hostName}" with domain: ${domain}`)
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
text: html,
|
||||
_meta: {
|
||||
ui: {
|
||||
csp: {
|
||||
resourceDomains: [
|
||||
'https://cdn.tldraw.com',
|
||||
'https://fonts.googleapis.com',
|
||||
'https://fonts.gstatic.com',
|
||||
...(opts.extraResourceDomains ?? []),
|
||||
'blob:',
|
||||
],
|
||||
connectDomains: ['https://cdn.tldraw.com', ...(opts.extraConnectDomains ?? [])],
|
||||
},
|
||||
permissions: { clipboardWrite: {} },
|
||||
...(domain ? { domain } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
registerAppResource(
|
||||
server,
|
||||
'tldraw-canvas',
|
||||
CANVAS_RESOURCE_URI,
|
||||
{
|
||||
title: 'tldraw Canvas',
|
||||
description: 'Interactive tldraw canvas.',
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => readCanvasResource(CANVAS_RESOURCE_URI)
|
||||
)
|
||||
|
||||
// Hosts cache the widget resource URI durably
|
||||
// (across connector re-adds and new chats), so a host may keep requesting a
|
||||
// URI variant this server no longer advertises. Serve the widget for any
|
||||
// ui://show-canvas/… URI instead of returning "resource not found" (-32602),
|
||||
// which breaks widget rendering.
|
||||
server.registerResource(
|
||||
'tldraw-canvas-compat',
|
||||
new ResourceTemplate('ui://show-canvas/{version}/mcp-app.html', { list: undefined }),
|
||||
{
|
||||
title: 'tldraw Canvas',
|
||||
description: 'Interactive tldraw canvas (cached-URI compatibility).',
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
},
|
||||
async (uri): Promise<ReadResourceResult> => readCanvasResource(uri.href)
|
||||
)
|
||||
}
|
||||
26
apps/mcp-app/src/shared/exec-key.ts
Normal file
26
apps/mcp-app/src/shared/exec-key.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Rendezvous key for handing the widget's exec result back to the server.
|
||||
*
|
||||
* The server (from the tool args) and the widget (from the code it executes)
|
||||
* derive the same key independently, from the two values both sides are
|
||||
* guaranteed to share regardless of how the host routes MCP sessions: the exec
|
||||
* `code` and the model-supplied `canvasId`. Used to address the `exec:<key>`
|
||||
* CanvasStore rendezvous DO.
|
||||
*
|
||||
* Folding in the canvasId is what prevents a harmful collision. Two invocations
|
||||
* only collide when they hash to the same key, which (SHA-256) means identical
|
||||
* code. Identical code on *different* existing canvases would otherwise share a
|
||||
* key and could swap results — but those invocations carry different canvasIds,
|
||||
* so the key differs. When no canvasId is supplied (a brand-new blank canvas)
|
||||
* the key falls back to the code alone; collisions there are harmless because
|
||||
* identical code on identical blank canvases produces identical results.
|
||||
*
|
||||
* Runs in both the Cloudflare worker and the widget (browser).
|
||||
*/
|
||||
export async function computeExecKey(code: string, canvasId?: string): Promise<string> {
|
||||
const material = canvasId ? `${canvasId}\n${code}` : code
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(material))
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 32)
|
||||
}
|
||||
160
apps/mcp-app/src/shared/generated-data.ts
Normal file
160
apps/mcp-app/src/shared/generated-data.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
type ArgKind =
|
||||
| 'id'
|
||||
| 'id-or-shape'
|
||||
| 'ids-or-shapes'
|
||||
| 'spread-ids'
|
||||
| 'shape-partial'
|
||||
| 'shape-partials'
|
||||
| 'update-partial'
|
||||
| 'update-partials'
|
||||
|
||||
export type RetKind =
|
||||
| 'this'
|
||||
| 'shape'
|
||||
| 'shape-or-null'
|
||||
| 'shapes'
|
||||
| 'id'
|
||||
| 'id-or-null'
|
||||
| 'ids'
|
||||
| 'id-set'
|
||||
|
||||
interface MethodSpec {
|
||||
args: ArgKind[]
|
||||
ret: RetKind
|
||||
}
|
||||
|
||||
export type MethodMap = Record<string, MethodSpec>
|
||||
|
||||
export interface EditorApiSpec {
|
||||
extractedAt: string
|
||||
memberCount: number
|
||||
categories: string[]
|
||||
members: unknown[]
|
||||
types: {
|
||||
shapeTypes: string[]
|
||||
shapes: unknown[]
|
||||
}
|
||||
helperCount: number
|
||||
helpers: unknown[]
|
||||
}
|
||||
|
||||
interface AssetFetcher {
|
||||
fetch(input: Request): Promise<Response>
|
||||
}
|
||||
|
||||
const GENERATED_ASSET_BASE_URL = 'https://assets.local/'
|
||||
|
||||
let cachedEmbeddedMethodMap: MethodMap | null | undefined
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseMethodMap(value: unknown): MethodMap {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error('Generated method map is missing or invalid.')
|
||||
}
|
||||
|
||||
const entries = Object.entries(value).map(([methodName, spec]) => {
|
||||
if (!isPlainObject(spec) || !Array.isArray(spec.args) || typeof spec.ret !== 'string') {
|
||||
throw new Error(`Generated method map entry "${methodName}" is invalid.`)
|
||||
}
|
||||
|
||||
return [
|
||||
methodName,
|
||||
{
|
||||
args: spec.args as ArgKind[],
|
||||
ret: spec.ret as RetKind,
|
||||
},
|
||||
] as const
|
||||
})
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
function parseEditorApiSpec(value: unknown): EditorApiSpec {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error('Generated editor API spec is missing or invalid.')
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(value.categories) ||
|
||||
!Array.isArray(value.members) ||
|
||||
!isPlainObject(value.types) ||
|
||||
!Array.isArray(value.types.shapeTypes) ||
|
||||
!Array.isArray(value.types.shapes) ||
|
||||
!Array.isArray(value.helpers)
|
||||
) {
|
||||
throw new Error('Generated editor API spec is malformed.')
|
||||
}
|
||||
|
||||
return {
|
||||
extractedAt: typeof value.extractedAt === 'string' ? value.extractedAt : '',
|
||||
memberCount: typeof value.memberCount === 'number' ? value.memberCount : value.members.length,
|
||||
categories: value.categories.filter(
|
||||
(category): category is string => typeof category === 'string'
|
||||
),
|
||||
members: value.members,
|
||||
types: {
|
||||
shapeTypes: value.types.shapeTypes.filter(
|
||||
(shapeType): shapeType is string => typeof shapeType === 'string'
|
||||
),
|
||||
shapes: value.types.shapes,
|
||||
},
|
||||
helperCount: typeof value.helperCount === 'number' ? value.helperCount : value.helpers.length,
|
||||
helpers: value.helpers,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGeneratedJsonFromAssets<T>(
|
||||
assets: AssetFetcher,
|
||||
filename: string,
|
||||
parser: (value: unknown) => T
|
||||
): Promise<T> {
|
||||
const response = await assets.fetch(new Request(new URL(filename, GENERATED_ASSET_BASE_URL)))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load generated asset "${filename}": ${response.status}`)
|
||||
}
|
||||
|
||||
return parser(await response.json())
|
||||
}
|
||||
|
||||
function readMethodMapFromBootstrap(): MethodMap | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const bootstrap = (window as Window & { __TLDRAW_BOOTSTRAP__?: unknown }).__TLDRAW_BOOTSTRAP__
|
||||
if (!isPlainObject(bootstrap) || !('methodMap' in bootstrap)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return parseMethodMap(bootstrap.methodMap)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEditorApiSpecFromAssets(assets: AssetFetcher): Promise<EditorApiSpec> {
|
||||
return loadGeneratedJsonFromAssets(assets, 'editor-api.json', parseEditorApiSpec)
|
||||
}
|
||||
|
||||
export async function loadMethodMapFromAssets(assets: AssetFetcher): Promise<MethodMap> {
|
||||
return loadGeneratedJsonFromAssets(assets, 'method-map.json', parseMethodMap)
|
||||
}
|
||||
|
||||
export function primeEmbeddedMethodMap(): void {
|
||||
if (cachedEmbeddedMethodMap !== undefined) return
|
||||
cachedEmbeddedMethodMap = readMethodMapFromBootstrap()
|
||||
}
|
||||
|
||||
export function getRequiredEmbeddedMethodMap(): MethodMap {
|
||||
if (cachedEmbeddedMethodMap === undefined) {
|
||||
cachedEmbeddedMethodMap = readMethodMapFromBootstrap()
|
||||
}
|
||||
|
||||
if (!cachedEmbeddedMethodMap) {
|
||||
throw new Error('Missing embedded method map. Rebuild the widget assets and reload the app.')
|
||||
}
|
||||
|
||||
return cachedEmbeddedMethodMap
|
||||
}
|
||||
81
apps/mcp-app/src/shared/pending-requests.ts
Normal file
81
apps/mcp-app/src/shared/pending-requests.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Generic pending-request store for bridging async server↔widget communication.
|
||||
*
|
||||
* One pending request per named channel. The server creates a pending request
|
||||
* and awaits it; the widget resolves or rejects it via `callServerTool('_exec_callback')`.
|
||||
*
|
||||
* Callbacks without an active pending request are ignored. This prevents late
|
||||
* duplicate callbacks from being replayed into a later request on the same channel.
|
||||
*/
|
||||
|
||||
interface PendingEntry {
|
||||
resolve(value: unknown): void
|
||||
reject(reason: Error): void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export class PendingRequests {
|
||||
private pending = new Map<string, PendingEntry>()
|
||||
|
||||
/**
|
||||
* Create a pending request for the given channel.
|
||||
* Returns a promise that resolves when `resolve()` is called,
|
||||
* or rejects on timeout or if `reject()` is called.
|
||||
*
|
||||
* Throws if a request is already pending for this channel.
|
||||
*/
|
||||
create(channel: string, timeoutMs = 30_000): Promise<unknown> {
|
||||
if (this.pending.has(channel)) {
|
||||
throw new Error(`A request is already pending for channel "${channel}"`)
|
||||
}
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(channel)
|
||||
reject(new Error(`Callback timed out after ${timeoutMs}ms for channel "${channel}"`))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pending.set(channel, { resolve, reject, timer })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pending request for the given channel.
|
||||
* Returns false if no request is currently pending.
|
||||
*/
|
||||
resolve(channel: string, value: unknown): boolean {
|
||||
const entry = this.pending.get(channel)
|
||||
if (!entry) return false
|
||||
|
||||
clearTimeout(entry.timer)
|
||||
this.pending.delete(channel)
|
||||
entry.resolve(value)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the pending request for the given channel without settling its
|
||||
* promise. Used to clean up after the request was resolved another way
|
||||
* (e.g. via the rendezvous DO).
|
||||
*/
|
||||
cancel(channel: string): void {
|
||||
const entry = this.pending.get(channel)
|
||||
if (!entry) return
|
||||
clearTimeout(entry.timer)
|
||||
this.pending.delete(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the pending request for the given channel.
|
||||
* Returns false if no request is currently pending.
|
||||
*/
|
||||
reject(channel: string, error: string): boolean {
|
||||
const entry = this.pending.get(channel)
|
||||
if (!entry) return false
|
||||
|
||||
clearTimeout(entry.timer)
|
||||
this.pending.delete(channel)
|
||||
entry.reject(new Error(error))
|
||||
return true
|
||||
}
|
||||
}
|
||||
130
apps/mcp-app/src/shared/types.ts
Normal file
130
apps/mcp-app/src/shared/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { TLShape } from 'tldraw'
|
||||
import packageJson from '../../package.json'
|
||||
import type { EditorApiSpec, MethodMap } from './generated-data'
|
||||
import type { PendingRequests } from './pending-requests'
|
||||
|
||||
export interface PendingBootstrap {
|
||||
canvasId: string
|
||||
checkpointId: string | null
|
||||
}
|
||||
|
||||
/** Result of executing code in the widget, as reported via `_exec_callback`. */
|
||||
export interface ExecResultPayload {
|
||||
success: boolean
|
||||
result?: unknown
|
||||
error?: string
|
||||
/**
|
||||
* The canvasId the widget ran this code against, set only when the model
|
||||
* supplied a canvasId in the tool args. The waiting exec cross-checks it
|
||||
* against its own invocation so a rendezvous-key collision (identical code
|
||||
* from a different invocation) can't hand back another canvas's result.
|
||||
*/
|
||||
canvasId?: string
|
||||
}
|
||||
|
||||
export interface ServerDeps {
|
||||
saveCheckpoint(id: string, shapes: TLShape[], assets?: unknown[], bindings?: unknown[]): void
|
||||
loadCheckpoint(id: string): { shapes: unknown[]; assets: unknown[]; bindings: unknown[] } | null
|
||||
getActiveCheckpointId(): string | null
|
||||
setActiveCheckpointId(id: string): void
|
||||
getCanvasCheckpointId(canvasId: string): string | null
|
||||
setCanvasCheckpointId(canvasId: string, checkpointId: string): void
|
||||
setPendingBootstrap(bootstrap: PendingBootstrap): void
|
||||
consumePendingBootstrap(): PendingBootstrap | null
|
||||
getSessionId(): string
|
||||
getMcpSessionId(): string
|
||||
loadWidgetHtml(): Promise<string>
|
||||
loadEditorApiSpec(): Promise<EditorApiSpec>
|
||||
loadMethodMap(): Promise<MethodMap>
|
||||
/** Hand an exec result to the rendezvous DO. Returns true if a waiter received it. */
|
||||
putExecResult(execKey: string, payload: ExecResultPayload): Promise<boolean>
|
||||
/**
|
||||
* Wait on the rendezvous DO for an exec result; null on timeout. `notBefore`
|
||||
* is the caller's start time — stashed results produced before it (a prior
|
||||
* invocation sharing this execKey) are discarded rather than returned.
|
||||
*/
|
||||
waitExecResult(
|
||||
execKey: string,
|
||||
timeoutMs: number,
|
||||
notBefore: number
|
||||
): Promise<ExecResultPayload | null>
|
||||
}
|
||||
|
||||
export interface RegisterToolsOptions {
|
||||
/** Extra CSP resource domains (e.g. R2 image URLs). */
|
||||
extraResourceDomains?: string[]
|
||||
/** Extra CSP connect domains. */
|
||||
extraConnectDomains?: string[]
|
||||
/** Dynamic Workers loader for sandboxed server-side code execution. */
|
||||
searchWorkerLoader: DynamicWorkerLoader
|
||||
/** Public origin of the deployed MCP worker, used for host-specific widget domains. */
|
||||
workerOrigin?: string
|
||||
/** Flag so the tools, and thus the widget, know if they are running in dev mode. */
|
||||
isDev: boolean
|
||||
/** Logging function (defaults to console.error). */
|
||||
log?(...args: unknown[]): void
|
||||
/** Analytics engine dataset. */
|
||||
analytics?: AnalyticsEngineDataset
|
||||
/** Returns the resolved host name of the connected client. */
|
||||
getClientHostName(): MCP_APP_HOST_NAMES | undefined
|
||||
/** Pending requests store for widget→server callback bridge. */
|
||||
pendingRequests: PendingRequests
|
||||
}
|
||||
|
||||
export interface DynamicWorkerLoader {
|
||||
load(code: {
|
||||
compatibilityDate: string
|
||||
mainModule: string
|
||||
modules: Record<string, string | { js: string }>
|
||||
env?: unknown
|
||||
globalOutbound?: null
|
||||
}): {
|
||||
getEntrypoint(name?: string): unknown
|
||||
}
|
||||
}
|
||||
|
||||
export const MCP_SERVER_NAME = 'tldraw'
|
||||
export const MCP_SERVER_VERSION = packageJson.version
|
||||
export const MCP_SERVER_TITLE = 'tldraw Canvas'
|
||||
export const MCP_SERVER_DESCRIPTION =
|
||||
'An interactive tldraw canvas with tools for diagramming, drawing, and more.'
|
||||
export const MCP_SERVER_WEBSITE_URL = 'https://www.tldraw.com'
|
||||
export const MCP_SERVER_INSTRUCTIONS =
|
||||
'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.'
|
||||
|
||||
// This URI must stay STABLE across deploys: hosts bind the app widget to it at
|
||||
// connector registration and cache the widget HTML served here, so a stale
|
||||
// widget build can stay in use after a deploy. Keep the widget↔server protocol
|
||||
// backward compatible with older widgets (new `_exec_callback` fields optional).
|
||||
export const CANVAS_RESOURCE_URI = 'ui://show-canvas/mcp-app.html'
|
||||
|
||||
/** Must match `compatibility_date` in wrangler.toml. */
|
||||
export const WORKER_COMPATIBILITY_DATE = '2025-03-10'
|
||||
|
||||
/** Snapshots retained per session. The widget only ever shows the latest; lowered from 200 purely for storage. */
|
||||
export const MAX_CHECKPOINTS = 50
|
||||
|
||||
/** A session DO destroys itself after this long without a checkpoint save. */
|
||||
export const IDLE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* When the next `expireIfIdle` alarm should fire. Split out of the DO so the
|
||||
* failure branch is unit-testable.
|
||||
*
|
||||
* `failed` short-circuits: a check that threw has no usable `lastActivity`, so
|
||||
* the normal `lastActivity + ttl` would schedule a full TTL out and swallow the
|
||||
* backoff. Otherwise the floor keeps a legacy DO (whose `lastActivity + ttl` is
|
||||
* already in the past) from re-firing in a tight loop.
|
||||
*/
|
||||
export function nextExpiryTime(args: {
|
||||
failed: boolean
|
||||
lastActivity: number | null
|
||||
now: number
|
||||
ttlMs: number
|
||||
}): number {
|
||||
const { failed, lastActivity, now, ttlMs } = args
|
||||
if (failed) return now + 60 * 60_000
|
||||
return Math.max((lastActivity ?? now) + ttlMs, now + 60_000)
|
||||
}
|
||||
|
||||
export type MCP_APP_HOST_NAMES = 'cursor' | 'vscode' | 'claude' | 'chatgpt'
|
||||
132
apps/mcp-app/src/shared/utils.ts
Normal file
132
apps/mcp-app/src/shared/utils.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { TLShape } from 'tldraw'
|
||||
import type { MCP_APP_HOST_NAMES } from './types'
|
||||
|
||||
const CANVAS_ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const CANVAS_ID_LENGTH = 8
|
||||
|
||||
export function generateCanvasId(): string {
|
||||
const bytes = new Uint8Array(CANVAS_ID_LENGTH)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (b) => CANVAS_ID_CHARS[b % CANVAS_ID_CHARS.length]).join('')
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function parseTlShapes(value: unknown[]): TLShape[] {
|
||||
return value.filter(
|
||||
(s): s is TLShape => isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function errorResponse(toolName: string, err: unknown, hint?: string): CallToolResult {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const parts = [`[${toolName}] Error: ${message}`]
|
||||
if (hint) parts.push(hint)
|
||||
return {
|
||||
content: [{ type: 'text', text: parts.join('\n\n') }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// these are what we get from server.server.getClientVersion() in the worker
|
||||
export function resolveMcpAppHostNameFromServerInfo(
|
||||
potentialHostName: string
|
||||
): MCP_APP_HOST_NAMES | undefined {
|
||||
const normalizedPotentialHostName = potentialHostName.trim().toLowerCase()
|
||||
|
||||
if (normalizedPotentialHostName.includes('cursor-vscode')) return 'cursor' // we expect something like "cursor-vscode (via mcp-remote 0.1.37)"
|
||||
if (normalizedPotentialHostName.includes('visual studio code')) return 'vscode' // we expect something like "Visual Studio Code (via mcp-remote 0.1.37)"
|
||||
if (
|
||||
normalizedPotentialHostName.includes('openai-mcp') ||
|
||||
normalizedPotentialHostName.includes('chatgpt')
|
||||
)
|
||||
return 'chatgpt' // we expect something like "openai-mcp"
|
||||
if (normalizedPotentialHostName.includes('claude-ai')) return 'claude' // we expect something like "claude-ai (via mcp-remote 0.1.37)"
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// these are what we expect from app.getHostVersion() (called in the client)
|
||||
export function resolveMcpAppHostNameFromClientInfo(
|
||||
potentialHostName: string
|
||||
): MCP_APP_HOST_NAMES | undefined {
|
||||
const normalizedPotentialHostName = potentialHostName.trim().toLowerCase()
|
||||
|
||||
if (normalizedPotentialHostName.includes('cursor')) return 'cursor'
|
||||
if (normalizedPotentialHostName.includes('visual studio code')) return 'vscode'
|
||||
if (normalizedPotentialHostName.includes('chatgpt')) return 'chatgpt'
|
||||
if (normalizedPotentialHostName.includes('claude')) return 'claude'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const CODE_EDITOR_HOST_NAMES: MCP_APP_HOST_NAMES[] = ['cursor', 'vscode']
|
||||
const ANALYTICS_ENGINE_MAX_BLOB_BYTES = 16 * 1024
|
||||
const TRUNCATED_ANALYTICS_SUFFIX = '...[truncated]'
|
||||
|
||||
export function isHostCodeEditor(hostName: MCP_APP_HOST_NAMES): boolean {
|
||||
return CODE_EDITOR_HOST_NAMES.includes(hostName)
|
||||
}
|
||||
|
||||
function truncateUtf8String(value: string, maxBytes: number): string {
|
||||
const encoder = new TextEncoder()
|
||||
if (maxBytes <= 0) return ''
|
||||
if (encoder.encode(value).byteLength <= maxBytes) return value
|
||||
|
||||
const suffixByteLength = encoder.encode(TRUNCATED_ANALYTICS_SUFFIX).byteLength
|
||||
if (suffixByteLength >= maxBytes) {
|
||||
let low = 0
|
||||
let high = TRUNCATED_ANALYTICS_SUFFIX.length
|
||||
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2)
|
||||
if (encoder.encode(TRUNCATED_ANALYTICS_SUFFIX.slice(0, mid)).byteLength <= maxBytes) {
|
||||
low = mid
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return TRUNCATED_ANALYTICS_SUFFIX.slice(0, low)
|
||||
}
|
||||
|
||||
const maxValueBytes = maxBytes - suffixByteLength
|
||||
let low = 0
|
||||
let high = value.length
|
||||
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2)
|
||||
if (encoder.encode(value.slice(0, mid)).byteLength <= maxValueBytes) {
|
||||
low = mid
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return `${value.slice(0, low)}${TRUNCATED_ANALYTICS_SUFFIX}`
|
||||
}
|
||||
|
||||
export function writeToolAnalytics(
|
||||
analytics: AnalyticsEngineDataset | undefined,
|
||||
toolName: string,
|
||||
code: string
|
||||
) {
|
||||
if (!analytics) return
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const baseBlobs = ['tool_called', toolName]
|
||||
const maxCodeBytes =
|
||||
ANALYTICS_ENGINE_MAX_BLOB_BYTES -
|
||||
baseBlobs.reduce((total, blob) => total + encoder.encode(blob).byteLength, 0)
|
||||
|
||||
try {
|
||||
analytics.writeDataPoint({
|
||||
blobs: [...baseBlobs, truncateUtf8String(code, maxCodeBytes)],
|
||||
})
|
||||
} catch {
|
||||
// writeDataPoint returns immediately and never throws, so we won't know if it failed
|
||||
}
|
||||
}
|
||||
224
apps/mcp-app/src/tools/exec.ts
Normal file
224
apps/mcp-app/src/tools/exec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { registerAppTool } from '@modelcontextprotocol/ext-apps/server'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import { computeExecKey } from '../shared/exec-key'
|
||||
import type { PendingRequests } from '../shared/pending-requests'
|
||||
import { CANVAS_RESOURCE_URI } from '../shared/types'
|
||||
import type { ExecResultPayload, MCP_APP_HOST_NAMES } from '../shared/types'
|
||||
import { generateCanvasId, isHostCodeEditor, writeToolAnalytics } from '../shared/utils'
|
||||
|
||||
// Bounded wait for the widget's exec result. The widget runs the code and
|
||||
// pushes the resulting canvas state to the model context independently of this
|
||||
// wait, so a missed, slow, or misrouted result must never hang the tool: after
|
||||
// the window we return a graceful "still executing" result. When the result
|
||||
// arrives in time the model gets the full synchronous return value.
|
||||
const EXEC_CALLBACK_WAIT_MS = 4_000
|
||||
// Code-editor hosts (Cursor, VS Code) can deliver the result inline but boot
|
||||
// the widget cold on the first call, so give them a longer window.
|
||||
const EXEC_CALLBACK_WAIT_LONG_MS = 8_000
|
||||
|
||||
function isLongWaitHost(hostName: MCP_APP_HOST_NAMES): boolean {
|
||||
return isHostCodeEditor(hostName)
|
||||
}
|
||||
|
||||
export function registerExecTool(
|
||||
server: McpServer,
|
||||
opts: {
|
||||
analytics?: AnalyticsEngineDataset
|
||||
log(...args: unknown[]): void
|
||||
pendingRequests: PendingRequests
|
||||
getMcpSessionId(): string
|
||||
getClientHostName(): MCP_APP_HOST_NAMES | undefined
|
||||
waitExecResult(
|
||||
execKey: string,
|
||||
timeoutMs: number,
|
||||
notBefore: number
|
||||
): Promise<ExecResultPayload | null>
|
||||
}
|
||||
) {
|
||||
registerAppTool(
|
||||
server,
|
||||
'exec',
|
||||
{
|
||||
title: 'Execute Code',
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Shapes and text grow depending on the amount of text they have. Use clever scripting to ensure there are no unintended overlaps.
|
||||
|
||||
Examples:
|
||||
- Create a rectangle: editor.createShape({ _type: 'rectangle', shapeId: 'box1', x: 200, y: 120, w: 320, h: 180, text: 'Hello' })
|
||||
- Connect shapes with an arrow: editor.createShape({ _type: 'arrow', shapeId: 'a1', fromId: 'box1', toId: 'box2', x1: 0, y1: 0, x2: 100, y2: 0 })
|
||||
- Select and zoom: editor.select('box1'); editor.zoomToSelection()
|
||||
- Read shapes: return editor.getCurrentPageShapes()
|
||||
- Distribute evenly: editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
|
||||
- Box around shapes: boxShapes(['box1', 'box2'], { text: 'Group label', color: 'blue' })
|
||||
- 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' })`,
|
||||
inputSchema: z.object({
|
||||
code: z
|
||||
.string()
|
||||
.describe(
|
||||
'JavaScript code to execute. Has access to `editor` (tldraw Editor instance) and helper functions.'
|
||||
),
|
||||
canvasId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Canvas ID to edit. Omit to create a new blank canvas. Pass a canvasId from a previous exec result to continue editing that canvas.'
|
||||
),
|
||||
}),
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
_meta: { ui: { resourceUri: CANVAS_RESOURCE_URI } },
|
||||
},
|
||||
async ({
|
||||
code,
|
||||
canvasId: inputCanvasId,
|
||||
}: {
|
||||
code: string
|
||||
canvasId?: string
|
||||
}): Promise<CallToolResult> => {
|
||||
writeToolAnalytics(opts.analytics, 'exec', code)
|
||||
|
||||
const canvasId = inputCanvasId || generateCanvasId()
|
||||
|
||||
const hostName = opts.getClientHostName()
|
||||
const waitMs =
|
||||
hostName && isLongWaitHost(hostName) ? EXEC_CALLBACK_WAIT_LONG_MS : EXEC_CALLBACK_WAIT_MS
|
||||
|
||||
// Key on the model-supplied canvasId (not the generated fallback): it's
|
||||
// the value the widget also has, and it makes same-code-different-canvas
|
||||
// invocations derive different keys so results can't be swapped.
|
||||
const execKey = await computeExecKey(code, inputCanvasId)
|
||||
const startedAt = Date.now()
|
||||
opts.log(
|
||||
`[tldraw-mcp] exec start: canvasId=${canvasId}, existing=${Boolean(inputCanvasId)}, host=${hostName ?? 'unknown'}, waitMs=${waitMs}, execKey=${execKey}, mcpSessionId=${opts.getMcpSessionId()}, startedAt=${startedAt}`
|
||||
)
|
||||
|
||||
// The widget's result can come back two ways, raced with one shared window:
|
||||
// - the in-memory pending request, when the host routes `_exec_callback`
|
||||
// over this same session (Cursor, VS Code)
|
||||
// - the exec:<execKey> rendezvous DO, when the callback lands on a
|
||||
// different session DO (Claude, ChatGPT) and gets forwarded
|
||||
let localWait: Promise<unknown> | null = null
|
||||
try {
|
||||
localWait = opts.pendingRequests.create('exec', waitMs)
|
||||
} catch {
|
||||
// Another exec on this session is already waiting; rely on the rendezvous.
|
||||
localWait = null
|
||||
}
|
||||
|
||||
// Defense in depth against a rendezvous-key collision (identical `code`
|
||||
// from a different invocation hashing to the same exec:<execKey> DO): if
|
||||
// the delivered payload names a canvasId and it isn't the one this
|
||||
// invocation is editing, it belongs to someone else — drop it to null so
|
||||
// the legitimate result can still win the race (or we degrade gracefully).
|
||||
const validate = (payload: ExecResultPayload | null): ExecResultPayload | null => {
|
||||
if (payload && payload.canvasId && payload.canvasId !== canvasId) {
|
||||
opts.log(
|
||||
`[tldraw-mcp] exec result rejected: canvasId mismatch (expected=${canvasId}, got=${payload.canvasId}, execKey=${execKey})`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const sources: Array<Promise<ExecResultPayload | null>> = []
|
||||
if (localWait) {
|
||||
sources.push(
|
||||
localWait.then((value) => validate(value as ExecResultPayload)).catch(() => null)
|
||||
)
|
||||
}
|
||||
sources.push(
|
||||
opts
|
||||
.waitExecResult(execKey, waitMs, startedAt)
|
||||
.then(validate)
|
||||
.catch(() => null)
|
||||
)
|
||||
|
||||
const result = await new Promise<ExecResultPayload | null>((resolve) => {
|
||||
let remaining = sources.length
|
||||
let settled = false
|
||||
for (const source of sources) {
|
||||
void source.then((value) => {
|
||||
remaining--
|
||||
if (settled) return
|
||||
if (value) {
|
||||
settled = true
|
||||
resolve(value)
|
||||
} else if (remaining === 0) {
|
||||
settled = true
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Drop whichever waiter didn't win so the channel is free for the next exec.
|
||||
opts.pendingRequests.cancel('exec')
|
||||
|
||||
if (result === null) {
|
||||
// No result arrived within the bounded window. This is NOT a failure:
|
||||
// the widget still runs the code and pushes the resulting canvas state
|
||||
// to the model context on its own, so return a graceful (non-error)
|
||||
// message instead of hanging the tool.
|
||||
opts.log(
|
||||
`[tldraw-mcp] exec result not received within ${waitMs}ms (graceful): canvasId=${canvasId}, execKey=${execKey}, mcpSessionId=${opts.getMcpSessionId()}, elapsed=${Date.now() - startedAt}ms`
|
||||
)
|
||||
// structuredContent.canvasId reaches the widget via the host's
|
||||
// tool-result event — the reliable channel for teaching the widget its
|
||||
// server-assigned canvasId regardless of host session routing.
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
`Canvas ${canvasId} is rendering and your code is executing. The resulting canvas state will be attached to the conversation shortly (it may already be present).\n\n` +
|
||||
`Canvas ID: ${canvasId} — pass this canvasId to edit the same canvas again. If the expected shapes don't appear in the next canvas-state update, re-run exec with a corrected script.`,
|
||||
},
|
||||
],
|
||||
structuredContent: { canvasId },
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
opts.log(
|
||||
`[tldraw-mcp] exec failed via callback after ${Date.now() - startedAt}ms: ${result.error}`
|
||||
)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
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}`,
|
||||
},
|
||||
],
|
||||
structuredContent: { canvasId },
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const resultStr =
|
||||
result.result !== undefined ? JSON.stringify(result.result, null, 2) : undefined
|
||||
const lines = [
|
||||
resultStr
|
||||
? `Code executed successfully on canvas. Return value:\n${resultStr}`
|
||||
: 'Code executed successfully on canvas.',
|
||||
`\nCanvas ID: ${canvasId} — to edit this canvas again, pass this as the canvasId parameter.`,
|
||||
]
|
||||
|
||||
opts.log(
|
||||
`[tldraw-mcp] exec succeeded via callback after ${Date.now() - startedAt}ms, canvasId=${canvasId}`
|
||||
)
|
||||
return {
|
||||
content: [{ type: 'text', text: lines.join('\n') }],
|
||||
structuredContent: { canvasId },
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
16
apps/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts
Normal file
16
apps/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DIST_DIR = path.join(__dirname, '..', '..', 'dist')
|
||||
|
||||
let cachedHtml: string | null = null
|
||||
|
||||
export async function loadCachedCanvasWidgetHtml(): Promise<string> {
|
||||
if (cachedHtml) return cachedHtml
|
||||
const html = await fs.readFile(path.join(DIST_DIR, 'mcp-app.html'), 'utf-8')
|
||||
cachedHtml = html
|
||||
return html
|
||||
}
|
||||
150
apps/mcp-app/src/tools/search.ts
Normal file
150
apps/mcp-app/src/tools/search.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import type { EditorApiSpec } from '../shared/generated-data'
|
||||
import { WORKER_COMPATIBILITY_DATE } from '../shared/types'
|
||||
import type { DynamicWorkerLoader } from '../shared/types'
|
||||
import { writeToolAnalytics } from '../shared/utils'
|
||||
|
||||
const SEARCH_TIMEOUT_MS = 5_000
|
||||
const SEARCH_RUNNER_MODULE = 'search-runner.js'
|
||||
const SEARCH_RUNNER_ENTRYPOINT = 'SearchRunner'
|
||||
|
||||
type SearchWorkerResult = { success: true; value: unknown } | { success: false; error: string }
|
||||
type SearchSpec = Pick<
|
||||
EditorApiSpec,
|
||||
'members' | 'categories' | 'types' | 'helperCount' | 'helpers'
|
||||
>
|
||||
|
||||
function createSearchRunnerModule(code: string) {
|
||||
return `import { WorkerEntrypoint } from 'cloudflare:workers'
|
||||
|
||||
function serializeResult(result) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(result))
|
||||
} catch {
|
||||
return String(result)
|
||||
}
|
||||
}
|
||||
|
||||
export class ${SEARCH_RUNNER_ENTRYPOINT} extends WorkerEntrypoint {
|
||||
async run() {
|
||||
try {
|
||||
const spec = this.env.SPEC
|
||||
const result = await (async () => {
|
||||
${code}
|
||||
})()
|
||||
return { success: true, value: serializeResult(result) }
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function runSearchInDynamicWorker(
|
||||
loader: DynamicWorkerLoader,
|
||||
spec: SearchSpec,
|
||||
code: string
|
||||
) {
|
||||
const worker = loader.load({
|
||||
compatibilityDate: WORKER_COMPATIBILITY_DATE,
|
||||
mainModule: SEARCH_RUNNER_MODULE,
|
||||
modules: {
|
||||
[SEARCH_RUNNER_MODULE]: {
|
||||
js: createSearchRunnerModule(code),
|
||||
},
|
||||
},
|
||||
env: {
|
||||
SPEC: spec,
|
||||
},
|
||||
globalOutbound: null,
|
||||
})
|
||||
|
||||
const entrypoint = worker.getEntrypoint(SEARCH_RUNNER_ENTRYPOINT) as {
|
||||
run(): Promise<SearchWorkerResult>
|
||||
}
|
||||
const result = await Promise.race([
|
||||
entrypoint.run(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Search timed out after ${SEARCH_TIMEOUT_MS}ms`)),
|
||||
SEARCH_TIMEOUT_MS
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.value
|
||||
}
|
||||
|
||||
export function registerSearchTool(
|
||||
server: McpServer,
|
||||
opts: {
|
||||
analytics?: AnalyticsEngineDataset
|
||||
loader: DynamicWorkerLoader
|
||||
loadSpec(): Promise<EditorApiSpec>
|
||||
log(...args: unknown[]): void
|
||||
}
|
||||
) {
|
||||
let specPromise: Promise<SearchSpec> | null = null
|
||||
|
||||
server.registerTool(
|
||||
'search',
|
||||
{
|
||||
title: 'Search Editor API',
|
||||
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).
|
||||
|
||||
Examples:
|
||||
- Find shape methods: return spec.members.filter(m => m.category === "shapes").map(m => ({ name: m.name, signature: m.signature }))
|
||||
- Get arrow shape props: return spec.types.shapes.find(s => s.shapeType === "arrow")
|
||||
- List all categories: return spec.categories
|
||||
- Find a helper: return spec.helpers.find(h => h.name === "createArrowBetweenShapes")`,
|
||||
inputSchema: z.object({
|
||||
code: z
|
||||
.string()
|
||||
.describe(
|
||||
'JavaScript code that receives `spec` and returns a result. Must use `return` to produce output.'
|
||||
),
|
||||
}),
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
},
|
||||
async ({ code }: { code: string }): Promise<CallToolResult> => {
|
||||
writeToolAnalytics(opts.analytics, 'search', code)
|
||||
opts.log('[tldraw-mcp] search called')
|
||||
|
||||
try {
|
||||
specPromise ??= opts.loadSpec().then((editorApi) => ({
|
||||
members: editorApi.members,
|
||||
categories: editorApi.categories,
|
||||
types: editorApi.types,
|
||||
helperCount: editorApi.helperCount,
|
||||
helpers: editorApi.helpers,
|
||||
}))
|
||||
const serialized = await runSearchInDynamicWorker(opts.loader, await specPromise, code)
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(serialized, null, 2) }],
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
29
apps/mcp-app/src/widget/app-context.tsx
Normal file
29
apps/mcp-app/src/widget/app-context.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type App, type McpUiDisplayMode } from '@modelcontextprotocol/ext-apps/react'
|
||||
import { createContext } from 'react'
|
||||
import type { MCP_APP_HOST_NAMES } from '../shared/types'
|
||||
|
||||
export const McpAppContext = createContext<{
|
||||
displayMode: McpUiDisplayMode
|
||||
toggleFullscreen: (() => Promise<void>) | null
|
||||
canFullscreen: boolean
|
||||
canDownload: boolean
|
||||
app: App | null
|
||||
lastEditor: 'user' | 'ai'
|
||||
hostName: MCP_APP_HOST_NAMES | null
|
||||
isDev: boolean
|
||||
isDevLogVisible: boolean
|
||||
toggleDevLog: (() => void) | null
|
||||
logIfDevMode: ((message: string) => void) | null
|
||||
}>({
|
||||
displayMode: 'inline',
|
||||
toggleFullscreen: null,
|
||||
canFullscreen: true,
|
||||
canDownload: true,
|
||||
app: null,
|
||||
lastEditor: 'ai',
|
||||
hostName: null,
|
||||
isDev: false,
|
||||
isDevLogVisible: false,
|
||||
toggleDevLog: null,
|
||||
logIfDevMode: null,
|
||||
})
|
||||
70
apps/mcp-app/src/widget/dev-log.tsx
Normal file
70
apps/mcp-app/src/widget/dev-log.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
const MAX_DEV_LOG_ENTRIES = 200
|
||||
export const DEV_LOG_PANEL_HEIGHT = 140
|
||||
export const DEV_LOG_PANEL_GAP = 8
|
||||
|
||||
export function useDevLog() {
|
||||
const [isDev, setIsDev] = useState(false)
|
||||
const [isDevLogVisible, setIsDevLogVisible] = useState(false)
|
||||
const [devLogEntries, setDevLogEntries] = useState<string[]>([])
|
||||
const isDevRef = useRef(false)
|
||||
|
||||
const logIfDevMode = useCallback((message: string) => {
|
||||
if (!isDevRef.current) return
|
||||
setDevLogEntries((entries) => {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
const nextEntries = [...entries, `[${timestamp}] ${message}`]
|
||||
return nextEntries.slice(-MAX_DEV_LOG_ENTRIES)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleDevLog = useCallback(() => {
|
||||
setIsDevLogVisible((visible) => !visible)
|
||||
}, [])
|
||||
|
||||
const enableDevMode = useCallback(() => {
|
||||
isDevRef.current = true
|
||||
setIsDev(true)
|
||||
setIsDevLogVisible(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isDev,
|
||||
isDevLogVisible,
|
||||
devLogEntries,
|
||||
isDevRef,
|
||||
logIfDevMode,
|
||||
toggleDevLog,
|
||||
enableDevMode,
|
||||
}
|
||||
}
|
||||
|
||||
export function DevLogPanel({
|
||||
entries,
|
||||
isFullscreen,
|
||||
}: {
|
||||
entries: string[]
|
||||
isFullscreen: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: isFullscreen ? '0 0 160px' : undefined,
|
||||
minHeight: 80,
|
||||
maxHeight: isFullscreen ? 200 : DEV_LOG_PANEL_HEIGHT,
|
||||
overflow: 'auto',
|
||||
padding: 12,
|
||||
border: '1px solid var(--tl-color-muted-2)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--tl-color-panel)',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{entries.length > 0 ? entries.join('\n') : 'Dev log ready.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
apps/mcp-app/src/widget/exec-helpers.test.ts
Normal file
99
apps/mcp-app/src/widget/exec-helpers.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Editor } from 'tldraw'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Regression coverage for the "Illegal invocation" exec failure.
|
||||
*
|
||||
* Chromium's window.setTimeout brand-checks its receiver: invoking a captured
|
||||
* reference as a method of another object (`captured.setTimeout(...)`) throws
|
||||
* "TypeError: Illegal invocation". The exec timeout race used to do exactly
|
||||
* that, so its promise rejected synchronously and any exec code containing an
|
||||
* `await` lost the race and reported "Illegal invocation" while its shapes
|
||||
* kept appearing.
|
||||
*
|
||||
* jsdom's timers don't brand-check, so this suite installs a simulated
|
||||
* brand-checking setTimeout BEFORE exec-helpers captures its globals at module
|
||||
* load. A regression to the unbound invocation makes the async test below fail
|
||||
* with exactly the production error.
|
||||
*/
|
||||
|
||||
let executeCode: typeof import('./exec-helpers').executeCode
|
||||
|
||||
beforeAll(async () => {
|
||||
const native = window.setTimeout.bind(window)
|
||||
function brandCheckedSetTimeout(this: unknown, ...args: Parameters<typeof setTimeout>) {
|
||||
if (this !== window && this !== undefined && this !== globalThis) {
|
||||
throw new TypeError('Illegal invocation')
|
||||
}
|
||||
return native(...args)
|
||||
}
|
||||
window.setTimeout = brandCheckedSetTimeout as unknown as typeof window.setTimeout
|
||||
|
||||
// executeCode resolves the focused proxy's method map from the widget
|
||||
// bootstrap; an empty map means every editor call passes through, which is
|
||||
// fine — these tests never touch the editor.
|
||||
;(window as { __TLDRAW_BOOTSTRAP__?: unknown }).__TLDRAW_BOOTSTRAP__ = { methodMap: {} }
|
||||
|
||||
// Import AFTER the brand check is installed so the module captures the
|
||||
// simulated timer, exactly as it captures Chromium's native one.
|
||||
;({ executeCode } = await import('./exec-helpers'))
|
||||
// The import transforms the whole tldraw package on a cold cache, which can
|
||||
// exceed vitest's default 10s hook timeout in CI.
|
||||
}, 60_000)
|
||||
|
||||
// The Blob-URL module loader cannot run under vitest (Node cannot import
|
||||
// blob: URLs); compile the exec module with an async Function instead. The
|
||||
// sandbox and timeout-race behavior under test is unaffected.
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new (
|
||||
...args: string[]
|
||||
) => (editor: unknown, helpers: unknown) => Promise<unknown>
|
||||
|
||||
async function loadModule(code: string) {
|
||||
const compiled = new AsyncFunction('editor', 'helpers', code)
|
||||
return async ({ editor, helpers }: { editor: Editor; helpers: unknown }) =>
|
||||
compiled(editor, helpers)
|
||||
}
|
||||
|
||||
const stubEditor = {} as Editor
|
||||
|
||||
describe('the simulated brand check', () => {
|
||||
it('throws Illegal invocation for object-method calls, like Chromium', () => {
|
||||
const captured = { setTimeout: window.setTimeout }
|
||||
expect(() => captured.setTimeout(() => {}, 1)).toThrow('Illegal invocation')
|
||||
})
|
||||
|
||||
it('allows window-bound calls', () => {
|
||||
const bound = window.setTimeout.bind(window)
|
||||
const id = bound(() => {}, 1)
|
||||
expect(id).toBeDefined()
|
||||
clearTimeout(id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeCode timeout race', () => {
|
||||
it('completes async exec code (the case that used to report Illegal invocation)', async () => {
|
||||
const result = await executeCode(
|
||||
stubEditor,
|
||||
'await new Promise((resolve) => queueMicrotask(resolve)); return 42',
|
||||
{ loadModule }
|
||||
)
|
||||
expect(result).toEqual({ success: true, result: 42 })
|
||||
})
|
||||
|
||||
it('completes synchronous exec code', async () => {
|
||||
const result = await executeCode(stubEditor, 'return 1 + 2', { loadModule })
|
||||
expect(result).toEqual({ success: true, result: 3 })
|
||||
})
|
||||
|
||||
it('reports runtime errors from exec code', async () => {
|
||||
const result = await executeCode(stubEditor, 'throw new Error("boom")', { loadModule })
|
||||
expect(result).toEqual({ success: false, error: 'boom' })
|
||||
})
|
||||
|
||||
it('restores the sandboxed globals after execution', async () => {
|
||||
await executeCode(stubEditor, 'return 0', { loadModule })
|
||||
expect(typeof window.setTimeout).toBe('function')
|
||||
expect(typeof window.fetch).toBe('function')
|
||||
})
|
||||
})
|
||||
279
apps/mcp-app/src/widget/exec-helpers.ts
Normal file
279
apps/mcp-app/src/widget/exec-helpers.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Box,
|
||||
type Editor,
|
||||
Mat,
|
||||
TLShapeId,
|
||||
Vec,
|
||||
clamp,
|
||||
createBindingId,
|
||||
createShapeId,
|
||||
degreesToRadians,
|
||||
fitFrameToContent,
|
||||
getArrowBindings,
|
||||
radiansToDegrees,
|
||||
toRichText,
|
||||
} from 'tldraw'
|
||||
import { getRequiredEmbeddedMethodMap } from '../shared/generated-data'
|
||||
import { createFocusedEditorProxy } from './focused/focused-editor-proxy'
|
||||
|
||||
function ensureTldrawShapeId(id: string): TLShapeId {
|
||||
if (id.startsWith('shape:')) return id as TLShapeId
|
||||
return ('shape:' + id) as TLShapeId
|
||||
}
|
||||
|
||||
function createArrowBetweenShapesFn(editor: Editor) {
|
||||
/**
|
||||
* Create an arrow shape that connects two existing shapes by their IDs.
|
||||
*
|
||||
* @param fromId - The shape ID to connect the arrow start to.
|
||||
* @param toId - The shape ID to connect the arrow end to.
|
||||
* @param opts - Optional arrow properties: a signed bend amount for the curve and a text label.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* createArrowBetweenShapes('box1', 'box2', { text: 'next', bend: 50 })
|
||||
*/
|
||||
return (fromId: string, toId: string, opts?: { bend?: number; text?: string }) => {
|
||||
const arrowId = createShapeId()
|
||||
const resolvedFromId = ensureTldrawShapeId(fromId)
|
||||
const resolvedToId = ensureTldrawShapeId(toId)
|
||||
editor.createShape({
|
||||
id: arrowId,
|
||||
type: 'arrow',
|
||||
props: {
|
||||
...(opts?.text ? { richText: toRichText(opts.text) } : {}),
|
||||
...(opts?.bend != null ? { bend: opts.bend } : {}),
|
||||
},
|
||||
})
|
||||
editor.createBindings([
|
||||
{
|
||||
id: createBindingId(),
|
||||
type: 'arrow',
|
||||
fromId: arrowId,
|
||||
toId: resolvedFromId,
|
||||
props: {
|
||||
terminal: 'start',
|
||||
isPrecise: false,
|
||||
isExact: false,
|
||||
normalizedAnchor: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: createBindingId(),
|
||||
type: 'arrow',
|
||||
fromId: arrowId,
|
||||
toId: resolvedToId,
|
||||
props: {
|
||||
terminal: 'end',
|
||||
isPrecise: false,
|
||||
isExact: false,
|
||||
normalizedAnchor: { x: 0.5, y: 0.5 },
|
||||
},
|
||||
},
|
||||
])
|
||||
return editor
|
||||
}
|
||||
}
|
||||
|
||||
const BOX_SHAPES_MARGIN = 40
|
||||
|
||||
function boxShapesFn(editor: Editor) {
|
||||
/**
|
||||
* Create a rectangle shape around a group of existing shapes with a margin. Also groups the shapes together.
|
||||
*
|
||||
* @param shapesOrIds - Array of shape IDs or shape objects to box around.
|
||||
* @param opts - Optional properties: shapeId, color, fill, text, note.
|
||||
*
|
||||
* @example
|
||||
* boxShapes(['box1', 'box2'], { text: 'Group A', color: 'blue' })
|
||||
*/
|
||||
return (
|
||||
shapesOrIds: (string | { shapeId: string })[],
|
||||
opts?: { shapeId?: string; color?: string; fill?: string; text?: string; note?: string }
|
||||
) => {
|
||||
const ids = shapesOrIds.map((s) =>
|
||||
typeof s === 'string' ? ensureTldrawShapeId(s) : ensureTldrawShapeId(s.shapeId)
|
||||
)
|
||||
|
||||
const bounds = editor.getShapesPageBounds(ids)
|
||||
if (!bounds) return editor
|
||||
|
||||
const boxId = opts?.shapeId ? ensureTldrawShapeId(opts.shapeId) : createShapeId()
|
||||
|
||||
editor.createShape({
|
||||
id: boxId,
|
||||
type: 'geo',
|
||||
x: bounds.x - BOX_SHAPES_MARGIN,
|
||||
y: bounds.y - BOX_SHAPES_MARGIN,
|
||||
props: {
|
||||
geo: 'rectangle',
|
||||
w: bounds.w + BOX_SHAPES_MARGIN * 2,
|
||||
h: bounds.h + BOX_SHAPES_MARGIN * 2,
|
||||
color: (opts?.color ?? 'black') as any,
|
||||
fill: 'none' as any,
|
||||
align: 'start' as any,
|
||||
verticalAlign: 'start' as any,
|
||||
...(opts?.text ? { richText: toRichText(opts.text) } : {}),
|
||||
},
|
||||
meta: { note: opts?.note ?? '' },
|
||||
})
|
||||
|
||||
editor.sendToBack([boxId])
|
||||
editor.groupShapes([...ids, boxId])
|
||||
|
||||
return editor
|
||||
}
|
||||
}
|
||||
|
||||
export function createExecHelpers(editor: Editor) {
|
||||
const helpers = {
|
||||
createShapeId,
|
||||
createBindingId,
|
||||
Box,
|
||||
Vec,
|
||||
Mat,
|
||||
clamp,
|
||||
degreesToRadians,
|
||||
radiansToDegrees,
|
||||
getArrowBindings,
|
||||
fitFrameToContent,
|
||||
createArrowBetweenShapes: createArrowBetweenShapesFn(editor),
|
||||
boxShapes: boxShapesFn(editor),
|
||||
}
|
||||
|
||||
return helpers
|
||||
}
|
||||
|
||||
type ExecHelpers = ReturnType<typeof createExecHelpers>
|
||||
|
||||
const EXEC_TIMEOUT_MS = 10_000
|
||||
|
||||
// Exec code runs with fetch/XHR/timers disabled as a lightweight sandbox. The
|
||||
// globals are captured ONCE here, before any exec can disable them, and a depth
|
||||
// counter tracks overlapping runs. This makes disable/restore re-entrancy safe:
|
||||
// hosts can start a second exec while an earlier one is still awaiting (e.g. a
|
||||
// debounced re-run firing during a slow canvas-state fetch), and without this a
|
||||
// second run would capture an already-`undefined` timer as its "original" and
|
||||
// restore that, permanently clobbering `window.setTimeout` for every later exec.
|
||||
const REAL_TIMERS = {
|
||||
fetch: typeof window !== 'undefined' ? window.fetch : undefined,
|
||||
XMLHttpRequest: typeof window !== 'undefined' ? window.XMLHttpRequest : undefined,
|
||||
setInterval: typeof window !== 'undefined' ? window.setInterval : undefined,
|
||||
setTimeout: typeof window !== 'undefined' ? window.setTimeout : undefined,
|
||||
}
|
||||
|
||||
// Never invoke a captured timer as a method of the capture object: native
|
||||
// window functions brand-check `this`, so `REAL_TIMERS.setTimeout(...)`
|
||||
// throws "TypeError: Illegal invocation" in Chromium. That synchronous throw
|
||||
// rejects the exec timeout race immediately, so any exec code containing an
|
||||
// `await` loses the race and reports "Illegal invocation" while its shapes
|
||||
// keep appearing (fully synchronous scripts settle first, which is why this
|
||||
// only surfaces intermittently). Bind once at capture time for safe
|
||||
// invocation; the unbound originals above are only ever assigned back onto
|
||||
// window.
|
||||
const safeSetTimeout = REAL_TIMERS.setTimeout
|
||||
? REAL_TIMERS.setTimeout.bind(window)
|
||||
: (undefined as never)
|
||||
|
||||
let execSandboxDepth = 0
|
||||
|
||||
function enterExecSandbox() {
|
||||
if (execSandboxDepth++ === 0) {
|
||||
;(window as any).fetch = undefined
|
||||
;(window as any).XMLHttpRequest = undefined
|
||||
;(window as any).setInterval = undefined
|
||||
;(window as any).setTimeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function exitExecSandbox() {
|
||||
// Only the outermost run restores, so an inner run finishing can't re-enable
|
||||
// timers while an outer run is still executing sandboxed code.
|
||||
if (execSandboxDepth > 0 && --execSandboxDepth === 0) {
|
||||
window.fetch = REAL_TIMERS.fetch!
|
||||
window.XMLHttpRequest = REAL_TIMERS.XMLHttpRequest!
|
||||
window.setInterval = REAL_TIMERS.setInterval!
|
||||
window.setTimeout = REAL_TIMERS.setTimeout!
|
||||
}
|
||||
}
|
||||
|
||||
function serializeResult(result: unknown) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(result))
|
||||
} catch {
|
||||
return result != null ? String(result) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExecModule(code: string) {
|
||||
const moduleSource = `export default async function runExec({ editor, helpers }) {
|
||||
const {
|
||||
createShapeId,
|
||||
createBindingId,
|
||||
Box,
|
||||
Vec,
|
||||
Mat,
|
||||
clamp,
|
||||
degreesToRadians,
|
||||
radiansToDegrees,
|
||||
getArrowBindings,
|
||||
fitFrameToContent,
|
||||
createArrowBetweenShapes,
|
||||
boxShapes,
|
||||
} = helpers
|
||||
|
||||
return await (async () => {
|
||||
${code}
|
||||
})()
|
||||
}`
|
||||
|
||||
const moduleUrl = URL.createObjectURL(new Blob([moduleSource], { type: 'text/javascript' }))
|
||||
try {
|
||||
return (await import(/* @vite-ignore */ moduleUrl)).default as ExecModule
|
||||
} finally {
|
||||
URL.revokeObjectURL(moduleUrl)
|
||||
}
|
||||
}
|
||||
|
||||
type ExecModule = (args: { editor: Editor; helpers: ExecHelpers }) => Promise<unknown>
|
||||
|
||||
export async function executeCode(
|
||||
editor: Editor,
|
||||
code: string,
|
||||
options?: {
|
||||
/**
|
||||
* Test seam: the Blob-URL module loader cannot run under vitest (Node
|
||||
* cannot import blob: URLs), so tests inject a compiler here. Production
|
||||
* callers never pass this.
|
||||
*/
|
||||
loadModule?(code: string): Promise<ExecModule>
|
||||
}
|
||||
): Promise<{ success: boolean; result?: unknown; error?: string }> {
|
||||
const focusedEditor = createFocusedEditorProxy(editor, getRequiredEmbeddedMethodMap())
|
||||
const helpers = createExecHelpers(editor)
|
||||
|
||||
// Disable fetch, XMLHttpRequest, and timers while the exec code runs.
|
||||
enterExecSandbox()
|
||||
|
||||
try {
|
||||
const runExec = await (options?.loadModule ?? loadExecModule)(code)
|
||||
const result = await Promise.race([
|
||||
runExec({ editor: focusedEditor, helpers }),
|
||||
new Promise((_, reject) =>
|
||||
// Use the pristine, window-bound timer (not the sandboxed,
|
||||
// possibly-undefined one).
|
||||
safeSetTimeout(
|
||||
() => reject(new Error(`Execution timed out after ${EXEC_TIMEOUT_MS}ms`)),
|
||||
EXEC_TIMEOUT_MS
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
return { success: true, result: serializeResult(result) }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
exitExecSandbox()
|
||||
}
|
||||
}
|
||||
35
apps/mcp-app/src/widget/export-tldr.ts
Normal file
35
apps/mcp-app/src/widget/export-tldr.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { App } from '@modelcontextprotocol/ext-apps/react'
|
||||
import { Editor, serializeTldrawJson, serializeTldrawJsonBlob } from 'tldraw'
|
||||
|
||||
export async function exportTldr(editor: Editor, app?: App) {
|
||||
const json = await serializeTldrawJson(editor)
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(json).catch(() => {
|
||||
// Clipboard may be unavailable in some contexts.
|
||||
})
|
||||
|
||||
// Download file
|
||||
if (app?.getHostCapabilities()?.downloadFile) {
|
||||
await app.downloadFile({
|
||||
contents: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///diagram.tldr',
|
||||
mimeType: 'application/json',
|
||||
text: json,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
const blob = await serializeTldrawJsonBlob(editor)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'diagram.tldr'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
141
apps/mcp-app/src/widget/focused/defaults.ts
Normal file
141
apps/mcp-app/src/widget/focused/defaults.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Default shape templates for creating new shapes from focused format.
|
||||
* Extracted from tldraw-internal desktop app handlers.ts.
|
||||
*/
|
||||
import { createShapeId, IndexKey, TLShape, toRichText } from 'tldraw'
|
||||
import { FOCUSED_TO_GEO_TYPES, type FocusedShape } from './format'
|
||||
|
||||
export function getDefaultShape(shapeType: FocusedShape['_type']): Partial<TLShape> {
|
||||
const isGeo = shapeType in FOCUSED_TO_GEO_TYPES
|
||||
if (isGeo) {
|
||||
return {
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
meta: {},
|
||||
id: createShapeId(),
|
||||
props: {
|
||||
align: 'middle',
|
||||
color: 'black',
|
||||
dash: 'draw',
|
||||
fill: 'none',
|
||||
font: 'draw',
|
||||
geo: 'rectangle',
|
||||
growY: 0,
|
||||
h: 200,
|
||||
labelColor: 'black',
|
||||
richText: toRichText(''),
|
||||
scale: 1,
|
||||
size: 'm',
|
||||
url: '',
|
||||
verticalAlign: 'middle',
|
||||
w: 200,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
switch (shapeType) {
|
||||
case 'text':
|
||||
return {
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
meta: {},
|
||||
id: createShapeId(),
|
||||
props: {
|
||||
autoSize: true,
|
||||
color: 'black',
|
||||
font: 'draw',
|
||||
richText: toRichText(''),
|
||||
scale: 1,
|
||||
size: 'm',
|
||||
textAlign: 'start',
|
||||
w: 100,
|
||||
},
|
||||
} as any
|
||||
case 'line':
|
||||
return {
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
meta: {},
|
||||
id: createShapeId(),
|
||||
props: {
|
||||
size: 'm',
|
||||
color: 'black',
|
||||
dash: 'draw',
|
||||
points: {
|
||||
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
|
||||
a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 0 },
|
||||
},
|
||||
scale: 1,
|
||||
spline: 'line',
|
||||
},
|
||||
} as any
|
||||
case 'arrow':
|
||||
return {
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
meta: {},
|
||||
id: createShapeId(),
|
||||
props: {
|
||||
arrowheadEnd: 'arrow',
|
||||
arrowheadStart: 'none',
|
||||
bend: 0,
|
||||
color: 'black',
|
||||
dash: 'draw',
|
||||
elbowMidPoint: 0.5,
|
||||
end: { x: 100, y: 0 },
|
||||
fill: 'none',
|
||||
font: 'draw',
|
||||
kind: 'arc',
|
||||
labelColor: 'black',
|
||||
labelPosition: 0.5,
|
||||
richText: toRichText(''),
|
||||
scale: 1,
|
||||
size: 'm',
|
||||
start: { x: 0, y: 0 },
|
||||
},
|
||||
} as any
|
||||
case 'note':
|
||||
return {
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
meta: {},
|
||||
id: createShapeId(),
|
||||
props: {
|
||||
color: 'black',
|
||||
richText: toRichText(''),
|
||||
size: 'm',
|
||||
align: 'middle',
|
||||
font: 'draw',
|
||||
fontSizeAdjustment: 0,
|
||||
growY: 0,
|
||||
labelColor: 'black',
|
||||
scale: 1,
|
||||
url: '',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
} as any
|
||||
case 'draw':
|
||||
return {
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
meta: {},
|
||||
id: createShapeId(),
|
||||
props: {},
|
||||
} as any
|
||||
default:
|
||||
return {
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
meta: {},
|
||||
id: createShapeId(),
|
||||
props: {},
|
||||
} as any
|
||||
}
|
||||
}
|
||||
452
apps/mcp-app/src/widget/focused/focused-editor-proxy.ts
Normal file
452
apps/mcp-app/src/widget/focused/focused-editor-proxy.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* ES Proxy that wraps the tldraw Editor to auto-convert between focused format
|
||||
* and tldraw's internal format at method boundaries.
|
||||
*
|
||||
* AI agents interact entirely in focused format — simple string IDs, flat shape
|
||||
* objects with `_type` — and the proxy silently translates to/from tldraw's
|
||||
* `TLShape`, `TLShapeId`, etc.
|
||||
*/
|
||||
import {
|
||||
Editor,
|
||||
TLBindingId,
|
||||
TLCreateShapePartial,
|
||||
TLShape,
|
||||
TLShapeId,
|
||||
TLShapePartial,
|
||||
} from 'tldraw'
|
||||
import type { MethodMap, RetKind } from '../../shared/generated-data'
|
||||
import { getDefaultShape } from './defaults'
|
||||
import { convertSimpleIdToTldrawId, convertTldrawIdToSimpleId, type FocusedShape } from './format'
|
||||
import { convertTldrawShapeToFocusedShape } from './to-focused'
|
||||
import { convertFocusedShapeToTldrawShape } from './to-tldraw'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input conversion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert a single value that might be a focused ID or focused shape into tldraw format. */
|
||||
function convertIdOrShape(val: string | TLShapeId | TLShape | FocusedShape | null | undefined) {
|
||||
if (val === null || val === undefined) return val
|
||||
if (typeof val === 'string') return ensureTldrawId(val)
|
||||
if ('_type' in val) {
|
||||
return ensureTldrawId((val as FocusedShape).shapeId)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
/** Convert an array where each element might be a focused ID or focused shape. */
|
||||
function convertIdsOrShapes(
|
||||
arr: Array<string | TLShapeId | TLShape | FocusedShape> | TLShapeId[] | TLShape[]
|
||||
) {
|
||||
return arr.map(convertIdOrShape)
|
||||
}
|
||||
|
||||
/** Ensure a string is a valid TLShapeId. Passthrough if already prefixed. */
|
||||
function ensureTldrawId(id: string): TLShapeId {
|
||||
if (id.startsWith('shape:')) return id as TLShapeId
|
||||
return convertSimpleIdToTldrawId(id)
|
||||
}
|
||||
|
||||
/** Detect whether a value is a focused shape (has `_type` field). */
|
||||
function isFocusedShape(
|
||||
val: FocusedShape | TLShapePartial | TLCreateShapePartial
|
||||
): val is FocusedShape {
|
||||
return '_type' in val
|
||||
}
|
||||
|
||||
/** Detect update payloads that use focused `shapeId` but omit `_type`. */
|
||||
function hasFocusedShapeId(
|
||||
val: FocusedShape | TLShapePartial | TLCreateShapePartial
|
||||
): val is TLShapePartial & { shapeId: string } {
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
'shapeId' in val &&
|
||||
typeof (val as { shapeId?: unknown }).shapeId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
/** Normalize raw tldraw shape partial IDs when models omit the `shape:` prefix. */
|
||||
function normalizeRawShapePartialId<T extends TLShapePartial>(partial: T): T {
|
||||
const rawId = partial.id
|
||||
if (typeof rawId !== 'string') return partial
|
||||
if (rawId.startsWith('shape:')) return partial
|
||||
return { ...partial, id: ensureTldrawId(rawId) } as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an incoming partial into focused shape format when possible.
|
||||
* - If `_type` is already present, returns as-is.
|
||||
* - If `shapeId` is present, infers `_type` from the existing canvas shape.
|
||||
* - Otherwise returns null, meaning callers should treat it as raw tldraw input.
|
||||
*/
|
||||
function toFocusedShapeIfPossible(
|
||||
editor: Editor,
|
||||
partial: FocusedShape | TLShapePartial | TLCreateShapePartial
|
||||
): FocusedShape | null {
|
||||
if (isFocusedShape(partial)) return partial
|
||||
if (!hasFocusedShapeId(partial)) return null
|
||||
|
||||
const shapeId = ensureTldrawId(partial.shapeId)
|
||||
const existingShape = editor.getShape(shapeId)
|
||||
if (!existingShape) return null
|
||||
|
||||
const focusedExisting = convertTldrawShapeToFocusedShape(editor, existingShape)
|
||||
const merged = {
|
||||
...focusedExisting,
|
||||
...partial,
|
||||
_type: focusedExisting._type,
|
||||
shapeId: focusedExisting.shapeId,
|
||||
}
|
||||
// `partial` may be a broad TLShapePartial union; runtime merge is safe here
|
||||
// because we anchor on a valid focused shape from the existing record.
|
||||
return merged as unknown as FocusedShape
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output conversion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function convertOutputShape(editor: Editor, shape: TLShape): FocusedShape {
|
||||
try {
|
||||
return convertTldrawShapeToFocusedShape(editor, shape)
|
||||
} catch {
|
||||
return {
|
||||
_type: 'unknown',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
subType: shape.type,
|
||||
note: '',
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTLShape(val: TLShape | FocusedShape | string | null | undefined): val is TLShape {
|
||||
if (val === null || val === undefined || typeof val === 'string') return false
|
||||
return 'typeName' in val && val.typeName === 'shape'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a return value from tldraw format to focused format based on the method spec.
|
||||
* Uses targeted casts because the Proxy handler is inherently dynamic dispatch —
|
||||
* the `result` type varies per method and can't be statically narrowed from the spec string.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
type ProxyResult =
|
||||
| TLShape
|
||||
| TLShape[]
|
||||
| TLShapeId
|
||||
| TLShapeId[]
|
||||
| Set<TLShapeId>
|
||||
| Editor
|
||||
| null
|
||||
| undefined
|
||||
| void
|
||||
|
||||
function convertReturnValue(editor: Editor, proxy: Editor, spec: RetKind, result: ProxyResult) {
|
||||
switch (spec) {
|
||||
case 'this':
|
||||
return proxy
|
||||
case 'shape': {
|
||||
const shape = result as TLShape | undefined
|
||||
return shape && isTLShape(shape) ? convertOutputShape(editor, shape) : result
|
||||
}
|
||||
case 'shape-or-null': {
|
||||
if (result === null || result === undefined) return result
|
||||
const shape = result as TLShape
|
||||
return isTLShape(shape) ? convertOutputShape(editor, shape) : result
|
||||
}
|
||||
case 'shapes': {
|
||||
const shapes = result as TLShape[]
|
||||
if (!Array.isArray(shapes)) return result
|
||||
return shapes.map((s) => (isTLShape(s) ? convertOutputShape(editor, s) : s))
|
||||
}
|
||||
case 'id': {
|
||||
const id = result as TLShapeId
|
||||
return typeof id === 'string' ? convertTldrawIdToSimpleId(id) : result
|
||||
}
|
||||
case 'id-or-null': {
|
||||
if (result === null || result === undefined) return result
|
||||
const id = result as TLShapeId
|
||||
return typeof id === 'string' ? convertTldrawIdToSimpleId(id) : result
|
||||
}
|
||||
case 'ids': {
|
||||
const ids = result as TLShapeId[]
|
||||
if (!Array.isArray(ids)) return result
|
||||
return ids.map((id) => (typeof id === 'string' ? convertTldrawIdToSimpleId(id) : id))
|
||||
}
|
||||
case 'id-set': {
|
||||
const idSet = result as Set<TLShapeId>
|
||||
if (!(idSet instanceof Set)) return result
|
||||
const out = new Set<string>()
|
||||
for (const id of idSet) {
|
||||
out.add(convertTldrawIdToSimpleId(id))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Special-case handlers for create/update (arrow bindings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CreateEditorMethod = (...args: Parameters<Editor['createShape']>) => Editor
|
||||
type UpdateEditorMethod = (...args: Parameters<Editor['updateShape']>) => Editor
|
||||
|
||||
function handleCreateShape(
|
||||
editor: Editor,
|
||||
proxy: Editor,
|
||||
partial: FocusedShape | TLCreateShapePartial,
|
||||
realMethod: CreateEditorMethod
|
||||
): Editor {
|
||||
if (!isFocusedShape(partial)) {
|
||||
realMethod.call(editor, partial)
|
||||
return proxy
|
||||
}
|
||||
|
||||
const result = convertFocusedShapeToTldrawShape(editor, partial, {
|
||||
defaultShape: getDefaultShape(partial._type),
|
||||
})
|
||||
|
||||
editor.createShape(result.shape)
|
||||
|
||||
if (result.bindings) {
|
||||
for (const binding of result.bindings) {
|
||||
editor.createBinding({
|
||||
type: binding.type,
|
||||
fromId: binding.fromId,
|
||||
toId: binding.toId,
|
||||
props: binding.props,
|
||||
meta: binding.meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
function handleCreateShapes(
|
||||
editor: Editor,
|
||||
proxy: Editor,
|
||||
partials: Array<FocusedShape | TLCreateShapePartial>,
|
||||
realMethod: CreateEditorMethod
|
||||
): Editor {
|
||||
if (!Array.isArray(partials)) {
|
||||
realMethod.call(editor, partials)
|
||||
return proxy
|
||||
}
|
||||
|
||||
for (const partial of partials) {
|
||||
handleCreateShape(editor, proxy, partial, realMethod)
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
function handleUpdateShape(
|
||||
editor: Editor,
|
||||
proxy: Editor,
|
||||
partial: FocusedShape | TLShapePartial,
|
||||
realMethod: UpdateEditorMethod
|
||||
): Editor {
|
||||
const focusedPartial = toFocusedShapeIfPossible(editor, partial)
|
||||
if (!focusedPartial) {
|
||||
realMethod.call(editor, normalizeRawShapePartialId(partial as TLShapePartial))
|
||||
return proxy
|
||||
}
|
||||
|
||||
const shapeId = ensureTldrawId(focusedPartial.shapeId)
|
||||
const existingShape = editor.getShape(shapeId)
|
||||
if (!existingShape) {
|
||||
return proxy
|
||||
}
|
||||
|
||||
const result = convertFocusedShapeToTldrawShape(editor, focusedPartial, {
|
||||
defaultShape: existingShape,
|
||||
})
|
||||
|
||||
editor.updateShape(result.shape)
|
||||
|
||||
if (result.bindings) {
|
||||
const existingBindings = editor.getBindingsFromShape(shapeId, 'arrow')
|
||||
for (const binding of existingBindings) {
|
||||
editor.deleteBinding(binding.id as TLBindingId)
|
||||
}
|
||||
for (const binding of result.bindings) {
|
||||
editor.createBinding({
|
||||
type: binding.type,
|
||||
fromId: binding.fromId,
|
||||
toId: binding.toId,
|
||||
props: binding.props,
|
||||
meta: binding.meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
function handleUpdateShapes(
|
||||
editor: Editor,
|
||||
proxy: Editor,
|
||||
partials: Array<FocusedShape | TLShapePartial>,
|
||||
realMethod: UpdateEditorMethod
|
||||
): Editor {
|
||||
if (!Array.isArray(partials)) {
|
||||
realMethod.call(editor, partials)
|
||||
return proxy
|
||||
}
|
||||
|
||||
for (const partial of partials) {
|
||||
handleUpdateShape(editor, proxy, partial, realMethod)
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Proxy factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createFocusedEditorProxy(editor: Editor, methodMap: MethodMap): Editor {
|
||||
const proxy: Editor = new Proxy(editor, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, receiver)
|
||||
|
||||
// Only intercept function calls on string-named properties
|
||||
if (typeof prop !== 'string' || typeof value !== 'function') {
|
||||
return value
|
||||
}
|
||||
|
||||
const spec = methodMap[prop]
|
||||
|
||||
// --- Special-case: create/update need binding handling ---
|
||||
if (prop === 'createShape') {
|
||||
return (partial: FocusedShape | TLCreateShapePartial) =>
|
||||
handleCreateShape(target, proxy, partial, value as CreateEditorMethod)
|
||||
}
|
||||
if (prop === 'createShapes') {
|
||||
return (partials: Array<FocusedShape | TLCreateShapePartial>) =>
|
||||
handleCreateShapes(target, proxy, partials, value as CreateEditorMethod)
|
||||
}
|
||||
if (prop === 'updateShape') {
|
||||
return (partial: FocusedShape | TLShapePartial) =>
|
||||
handleUpdateShape(target, proxy, partial, value as UpdateEditorMethod)
|
||||
}
|
||||
if (prop === 'updateShapes') {
|
||||
return (partials: Array<FocusedShape | TLShapePartial>) =>
|
||||
handleUpdateShapes(target, proxy, partials, value as UpdateEditorMethod)
|
||||
}
|
||||
if (prop === 'animateShape') {
|
||||
return (partial: FocusedShape | TLShapePartial, ...rest: [Record<string, number>?]) => {
|
||||
const focusedPartial = toFocusedShapeIfPossible(target, partial)
|
||||
if (focusedPartial) {
|
||||
const shapeId = ensureTldrawId(focusedPartial.shapeId)
|
||||
const existing = target.getShape(shapeId)
|
||||
if (existing) {
|
||||
const converted = convertFocusedShapeToTldrawShape(target, focusedPartial, {
|
||||
defaultShape: existing,
|
||||
})
|
||||
value.call(target, converted.shape, ...rest)
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
value.call(target, normalizeRawShapePartialId(partial as TLShapePartial), ...rest)
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
if (prop === 'animateShapes') {
|
||||
return (
|
||||
partials: Array<FocusedShape | TLShapePartial>,
|
||||
...rest: [Record<string, number>?]
|
||||
) => {
|
||||
if (Array.isArray(partials)) {
|
||||
const converted = partials.map((p) => {
|
||||
const focusedPartial = toFocusedShapeIfPossible(target, p)
|
||||
if (focusedPartial) {
|
||||
const shapeId = ensureTldrawId(focusedPartial.shapeId)
|
||||
const existing = target.getShape(shapeId)
|
||||
if (existing) {
|
||||
return convertFocusedShapeToTldrawShape(target, focusedPartial, {
|
||||
defaultShape: existing,
|
||||
}).shape
|
||||
}
|
||||
}
|
||||
return normalizeRawShapePartialId(p as TLShapePartial)
|
||||
})
|
||||
value.call(target, converted, ...rest)
|
||||
} else {
|
||||
value.call(target, partials, ...rest)
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
|
||||
// --- No spec: pass through, but still catch `this` returns ---
|
||||
if (!spec) {
|
||||
return (...args: Parameters<typeof value>) => {
|
||||
const result = value.apply(target, args)
|
||||
return result === target ? proxy : result
|
||||
}
|
||||
}
|
||||
|
||||
// --- Generic handler for mapped methods ---
|
||||
// The proxy handler is dynamic dispatch: args vary per intercepted method.
|
||||
// We use targeted casts inside the switch rather than a single static signature.
|
||||
return (...args: Parameters<typeof value>) => {
|
||||
const convertedArgs: Parameters<typeof value> = [...args]
|
||||
for (let i = 0; i < spec.args.length && i < convertedArgs.length; i++) {
|
||||
const kind = spec.args[i]
|
||||
const arg = convertedArgs[i]
|
||||
switch (kind) {
|
||||
case 'id':
|
||||
if (typeof arg === 'string') {
|
||||
convertedArgs[i] = ensureTldrawId(arg)
|
||||
}
|
||||
break
|
||||
case 'id-or-shape':
|
||||
convertedArgs[i] = convertIdOrShape(
|
||||
arg as string | TLShapeId | TLShape | FocusedShape
|
||||
)
|
||||
break
|
||||
case 'ids-or-shapes':
|
||||
// Models sometimes pass a single id where an array is expected.
|
||||
convertedArgs[i] = convertIdsOrShapes(
|
||||
Array.isArray(arg)
|
||||
? (arg as Array<string | TLShapeId | TLShape | FocusedShape>)
|
||||
: [arg as string | TLShapeId | TLShape | FocusedShape]
|
||||
)
|
||||
break
|
||||
case 'spread-ids': {
|
||||
// Models sometimes pass an array to variadic methods like
|
||||
// `select(['a', 'b'])` — flatten instead of crashing.
|
||||
const spread: unknown[] = []
|
||||
for (let j = i; j < convertedArgs.length; j++) {
|
||||
const value = convertedArgs[j]
|
||||
if (Array.isArray(value)) {
|
||||
spread.push(
|
||||
...value.map((v) =>
|
||||
convertIdOrShape(v as string | TLShapeId | TLShape | FocusedShape)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
spread.push(
|
||||
convertIdOrShape(value as string | TLShapeId | TLShape | FocusedShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
convertedArgs.length = i
|
||||
convertedArgs.push(...spread)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = value.apply(target, convertedArgs)
|
||||
return convertReturnValue(target, proxy, spec.ret, result)
|
||||
}
|
||||
},
|
||||
}) as Editor
|
||||
|
||||
return proxy
|
||||
}
|
||||
366
apps/mcp-app/src/widget/focused/format.ts
Normal file
366
apps/mcp-app/src/widget/focused/format.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Focused shape format types and conversion utilities.
|
||||
* Ported from tldraw/tldraw templates/agent/shared/format/
|
||||
*/
|
||||
import { TLDefaultFillStyle, TLDefaultSizeStyle, TLGeoShapeGeoStyle, TLShapeId } from 'tldraw'
|
||||
|
||||
// ---- Colors ----
|
||||
|
||||
export const FOCUSED_COLORS = [
|
||||
'red',
|
||||
'light-red',
|
||||
'green',
|
||||
'light-green',
|
||||
'blue',
|
||||
'light-blue',
|
||||
'orange',
|
||||
'yellow',
|
||||
'black',
|
||||
'violet',
|
||||
'light-violet',
|
||||
'grey',
|
||||
'white',
|
||||
] as const
|
||||
|
||||
type FocusedColor = (typeof FOCUSED_COLORS)[number]
|
||||
|
||||
export function asColor(color: string): FocusedColor {
|
||||
if (FOCUSED_COLORS.includes(color as FocusedColor)) {
|
||||
return color as FocusedColor
|
||||
}
|
||||
switch (color) {
|
||||
case 'pink':
|
||||
case 'light-pink':
|
||||
return 'light-violet'
|
||||
}
|
||||
return 'black'
|
||||
}
|
||||
|
||||
// ---- Fill ----
|
||||
|
||||
type FocusedFill = 'none' | 'tint' | 'background' | 'solid' | 'pattern'
|
||||
|
||||
const FOCUSED_TO_SHAPE_FILLS: Record<FocusedFill, TLDefaultFillStyle> = {
|
||||
none: 'none',
|
||||
solid: 'lined-fill',
|
||||
background: 'semi',
|
||||
tint: 'solid',
|
||||
pattern: 'pattern',
|
||||
}
|
||||
|
||||
const SHAPE_TO_FOCUSED_FILLS: Record<TLDefaultFillStyle, FocusedFill> = {
|
||||
none: 'none',
|
||||
fill: 'solid',
|
||||
'lined-fill': 'solid',
|
||||
semi: 'background',
|
||||
solid: 'tint',
|
||||
pattern: 'pattern',
|
||||
}
|
||||
|
||||
export function convertFocusedFillToTldrawFill(fill: FocusedFill): TLDefaultFillStyle {
|
||||
return FOCUSED_TO_SHAPE_FILLS[fill]
|
||||
}
|
||||
|
||||
export function convertTldrawFillToFocusedFill(fill: TLDefaultFillStyle): FocusedFill {
|
||||
return SHAPE_TO_FOCUSED_FILLS[fill]
|
||||
}
|
||||
|
||||
// ---- Font Size ----
|
||||
|
||||
const FONT_SIZE_MULTIPLIERS: Record<TLDefaultSizeStyle, number> = {
|
||||
s: 1.125,
|
||||
m: 1.5,
|
||||
l: 2.25,
|
||||
xl: 2.75,
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_FONT_SIZE = 16
|
||||
|
||||
export function convertFocusedFontSizeToTldrawFontSizeAndScale(
|
||||
targetFontSize: number,
|
||||
baseFontSize = DEFAULT_BASE_FONT_SIZE
|
||||
) {
|
||||
const fontSizeEntries = Object.entries(FONT_SIZE_MULTIPLIERS)
|
||||
let closestSize = fontSizeEntries[0]
|
||||
let closestPixelSize = closestSize[1] * baseFontSize
|
||||
let minDifference = Math.abs(targetFontSize - closestPixelSize)
|
||||
|
||||
for (const [size, multiplier] of fontSizeEntries) {
|
||||
const pixelSize = multiplier * baseFontSize
|
||||
const difference = Math.abs(targetFontSize - pixelSize)
|
||||
if (difference < minDifference) {
|
||||
minDifference = difference
|
||||
closestSize = [size, multiplier]
|
||||
closestPixelSize = pixelSize
|
||||
}
|
||||
}
|
||||
|
||||
const textSize = closestSize[0] as TLDefaultSizeStyle
|
||||
const scale = targetFontSize / closestPixelSize
|
||||
|
||||
return { textSize, scale }
|
||||
}
|
||||
|
||||
export function convertTldrawFontSizeAndScaleToFocusedFontSize(
|
||||
textSize: TLDefaultSizeStyle,
|
||||
scale: number,
|
||||
baseFontSize = DEFAULT_BASE_FONT_SIZE
|
||||
) {
|
||||
return Math.round(FONT_SIZE_MULTIPLIERS[textSize] * baseFontSize * scale)
|
||||
}
|
||||
|
||||
// ---- Geo Shape Types ----
|
||||
|
||||
export type FocusedGeoShapeType =
|
||||
| 'rectangle'
|
||||
| 'ellipse'
|
||||
| 'triangle'
|
||||
| 'diamond'
|
||||
| 'hexagon'
|
||||
| 'pill'
|
||||
| 'cloud'
|
||||
| 'x-box'
|
||||
| 'check-box'
|
||||
| 'heart'
|
||||
| 'pentagon'
|
||||
| 'octagon'
|
||||
| 'star'
|
||||
| 'parallelogram-right'
|
||||
| 'parallelogram-left'
|
||||
| 'trapezoid'
|
||||
| 'fat-arrow-right'
|
||||
| 'fat-arrow-left'
|
||||
| 'fat-arrow-up'
|
||||
| 'fat-arrow-down'
|
||||
|
||||
export const FOCUSED_TO_GEO_TYPES: Record<FocusedGeoShapeType, TLGeoShapeGeoStyle> = {
|
||||
rectangle: 'rectangle',
|
||||
ellipse: 'ellipse',
|
||||
triangle: 'triangle',
|
||||
diamond: 'diamond',
|
||||
hexagon: 'hexagon',
|
||||
pill: 'oval',
|
||||
cloud: 'cloud',
|
||||
'x-box': 'x-box',
|
||||
'check-box': 'check-box',
|
||||
heart: 'heart',
|
||||
pentagon: 'pentagon',
|
||||
octagon: 'octagon',
|
||||
star: 'star',
|
||||
'parallelogram-right': 'rhombus',
|
||||
'parallelogram-left': 'rhombus-2',
|
||||
trapezoid: 'trapezoid',
|
||||
'fat-arrow-right': 'arrow-right',
|
||||
'fat-arrow-left': 'arrow-left',
|
||||
'fat-arrow-up': 'arrow-up',
|
||||
'fat-arrow-down': 'arrow-down',
|
||||
} as const
|
||||
|
||||
export const GEO_TO_FOCUSED_TYPES: Record<TLGeoShapeGeoStyle, FocusedGeoShapeType> = {
|
||||
rectangle: 'rectangle',
|
||||
ellipse: 'ellipse',
|
||||
triangle: 'triangle',
|
||||
diamond: 'diamond',
|
||||
hexagon: 'hexagon',
|
||||
oval: 'pill',
|
||||
cloud: 'cloud',
|
||||
'x-box': 'x-box',
|
||||
'check-box': 'check-box',
|
||||
heart: 'heart',
|
||||
pentagon: 'pentagon',
|
||||
octagon: 'octagon',
|
||||
star: 'star',
|
||||
rhombus: 'parallelogram-right',
|
||||
'rhombus-2': 'parallelogram-left',
|
||||
trapezoid: 'trapezoid',
|
||||
'arrow-right': 'fat-arrow-right',
|
||||
'arrow-left': 'fat-arrow-left',
|
||||
'arrow-up': 'fat-arrow-up',
|
||||
'arrow-down': 'fat-arrow-down',
|
||||
} as const
|
||||
|
||||
// ---- ID Conversion ----
|
||||
|
||||
export function convertSimpleIdToTldrawId(id: string): TLShapeId {
|
||||
if (id.startsWith('shape:')) return id as TLShapeId
|
||||
return ('shape:' + id) as TLShapeId
|
||||
}
|
||||
|
||||
export function convertTldrawIdToSimpleId(id: TLShapeId): string {
|
||||
return id.slice(6)
|
||||
}
|
||||
|
||||
// ---- Text Anchor ----
|
||||
|
||||
export type FocusedTextAnchor =
|
||||
| 'bottom-center'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'center-left'
|
||||
| 'center-right'
|
||||
| 'center'
|
||||
| 'top-center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
|
||||
// ---- Focused Shape Types ----
|
||||
|
||||
/**
|
||||
* Geometric shapes like rectangles, ellipses, triangles, and other predefined forms.
|
||||
* The _type field determines the geometric form.
|
||||
*/
|
||||
export interface FocusedGeoShape {
|
||||
/** Geometric shape type */
|
||||
_type: FocusedGeoShapeType
|
||||
/** Shape color */
|
||||
color: FocusedColor
|
||||
/** Fill style */
|
||||
fill: FocusedFill
|
||||
/** Height in pixels */
|
||||
h: number
|
||||
/** Metadata note */
|
||||
note: string
|
||||
/** Unique shape identifier */
|
||||
shapeId: string
|
||||
/** Text label inside the shape */
|
||||
text?: string
|
||||
/** Text alignment */
|
||||
textAlign?: 'start' | 'middle' | 'end'
|
||||
/** Width in pixels */
|
||||
w: number
|
||||
/** X position */
|
||||
x: number
|
||||
/** Y position */
|
||||
y: number
|
||||
}
|
||||
|
||||
/** A straight line between two points. */
|
||||
export interface FocusedLineShape {
|
||||
/** Always 'line' */
|
||||
_type: 'line'
|
||||
/** Line color */
|
||||
color: FocusedColor
|
||||
/** Metadata note */
|
||||
note: string
|
||||
/** Unique shape identifier */
|
||||
shapeId: string
|
||||
/** Start X */
|
||||
x1: number
|
||||
/** End X */
|
||||
x2: number
|
||||
/** Start Y */
|
||||
y1: number
|
||||
/** End Y */
|
||||
y2: number
|
||||
}
|
||||
|
||||
/** A sticky note. */
|
||||
export interface FocusedNoteShape {
|
||||
/** Always 'note' */
|
||||
_type: 'note'
|
||||
/** Note color */
|
||||
color: FocusedColor
|
||||
/** Metadata note */
|
||||
note: string
|
||||
/** Unique shape identifier */
|
||||
shapeId: string
|
||||
/** Note text content */
|
||||
text?: string
|
||||
/** X position */
|
||||
x: number
|
||||
/** Y position */
|
||||
y: number
|
||||
}
|
||||
|
||||
/** A text shape for placing text on the canvas. */
|
||||
export interface FocusedTextShape {
|
||||
/** Always 'text' */
|
||||
_type: 'text'
|
||||
/** Where the (x,y) point anchors on the text bounding box */
|
||||
anchor: FocusedTextAnchor
|
||||
/** Text color */
|
||||
color: FocusedColor
|
||||
/** Font size in pixels */
|
||||
fontSize?: number
|
||||
/** Max width before wrapping (null = auto-size) */
|
||||
maxWidth: number | null
|
||||
/** Metadata note */
|
||||
note: string
|
||||
/** Unique shape identifier */
|
||||
shapeId: string
|
||||
/** Text content */
|
||||
text: string
|
||||
/** X position */
|
||||
x: number
|
||||
/** Y position */
|
||||
y: number
|
||||
}
|
||||
|
||||
/** An arrow connecting two points or shapes. Use fromId/toId to bind to shapes. */
|
||||
export interface FocusedArrowShape {
|
||||
/** Always 'arrow' */
|
||||
_type: 'arrow'
|
||||
/** Arrow color */
|
||||
color: FocusedColor
|
||||
/** Shape ID to bind the arrow start to */
|
||||
fromId: string | null
|
||||
/** Metadata note */
|
||||
note: string
|
||||
/** Unique shape identifier */
|
||||
shapeId: string
|
||||
/** Arrow label text */
|
||||
text?: string
|
||||
/** Shape ID to bind the arrow end to */
|
||||
toId: string | null
|
||||
/** Start X */
|
||||
x1: number
|
||||
/** End X */
|
||||
x2: number
|
||||
/** Start Y */
|
||||
y1: number
|
||||
/** End Y */
|
||||
y2: number
|
||||
/** Signed bend amount for the curve */
|
||||
bend?: number
|
||||
/** Arrow routing style */
|
||||
kind?: 'arc' | 'elbow'
|
||||
}
|
||||
|
||||
/** A freehand drawing. */
|
||||
export interface FocusedDrawShape {
|
||||
/** Always 'draw' */
|
||||
_type: 'draw'
|
||||
/** Stroke color */
|
||||
color: FocusedColor
|
||||
/** Fill style */
|
||||
fill?: FocusedFill
|
||||
/** Metadata note */
|
||||
note: string
|
||||
/** Unique shape identifier */
|
||||
shapeId: string
|
||||
}
|
||||
|
||||
/** Fallback for unsupported shape types. */
|
||||
export interface FocusedUnknownShape {
|
||||
/** Always 'unknown' */
|
||||
_type: 'unknown'
|
||||
/** Metadata note */
|
||||
note: string
|
||||
/** Unique shape identifier */
|
||||
shapeId: string
|
||||
/** Original tldraw shape type */
|
||||
subType: string
|
||||
/** X position */
|
||||
x: number
|
||||
/** Y position */
|
||||
y: number
|
||||
}
|
||||
|
||||
export type FocusedShape =
|
||||
| FocusedGeoShape
|
||||
| FocusedLineShape
|
||||
| FocusedNoteShape
|
||||
| FocusedTextShape
|
||||
| FocusedArrowShape
|
||||
| FocusedDrawShape
|
||||
| FocusedUnknownShape
|
||||
258
apps/mcp-app/src/widget/focused/to-focused.ts
Normal file
258
apps/mcp-app/src/widget/focused/to-focused.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Convert tldraw TLShape → FocusedShape.
|
||||
* Ported from tldraw/tldraw templates/agent/shared/format/convertTldrawShapeToFocusedShape.ts
|
||||
*/
|
||||
import {
|
||||
Box,
|
||||
createShapeId,
|
||||
Editor,
|
||||
isPageId,
|
||||
reverseRecordsDiff,
|
||||
TLArrowBinding,
|
||||
TLArrowShape,
|
||||
TLDrawShape,
|
||||
TLGeoShape,
|
||||
TLLineShape,
|
||||
TLNoteShape,
|
||||
TLShape,
|
||||
TLTextShape,
|
||||
Vec,
|
||||
} from 'tldraw'
|
||||
import {
|
||||
convertTldrawFillToFocusedFill,
|
||||
convertTldrawFontSizeAndScaleToFocusedFontSize,
|
||||
convertTldrawIdToSimpleId,
|
||||
GEO_TO_FOCUSED_TYPES,
|
||||
type FocusedArrowShape,
|
||||
type FocusedDrawShape,
|
||||
type FocusedGeoShape,
|
||||
type FocusedLineShape,
|
||||
type FocusedNoteShape,
|
||||
type FocusedShape,
|
||||
type FocusedTextAnchor,
|
||||
type FocusedTextShape,
|
||||
type FocusedUnknownShape,
|
||||
} from './format'
|
||||
|
||||
export function convertTldrawShapeToFocusedShape(editor: Editor, shape: TLShape): FocusedShape {
|
||||
switch (shape.type) {
|
||||
case 'text':
|
||||
return convertTextShapeToFocused(editor, shape as TLTextShape)
|
||||
case 'geo':
|
||||
return convertGeoShapeToFocused(editor, shape as TLGeoShape)
|
||||
case 'line':
|
||||
return convertLineShapeToFocused(editor, shape as TLLineShape)
|
||||
case 'arrow':
|
||||
return convertArrowShapeToFocused(editor, shape as TLArrowShape)
|
||||
case 'note':
|
||||
return convertNoteShapeToFocused(editor, shape as TLNoteShape)
|
||||
case 'draw':
|
||||
return convertDrawShapeToFocused(editor, shape as TLDrawShape)
|
||||
default:
|
||||
return convertUnknownShapeToFocused(editor, shape)
|
||||
}
|
||||
}
|
||||
|
||||
function convertDrawShapeToFocused(_editor: Editor, shape: TLDrawShape): FocusedDrawShape {
|
||||
return {
|
||||
_type: 'draw',
|
||||
color: shape.props.color,
|
||||
fill: convertTldrawFillToFocusedFill(shape.props.fill),
|
||||
note: (shape.meta.note as string) ?? '',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
}
|
||||
}
|
||||
|
||||
function convertTextShapeToFocused(editor: Editor, shape: TLTextShape): FocusedTextShape {
|
||||
const util = editor.getShapeUtil(shape)
|
||||
const text = util.getText(shape) ?? ''
|
||||
const bounds = getSimpleBounds(editor, shape)
|
||||
const textSize = shape.props.size
|
||||
const baseFontSize = editor.getTheme('default')?.fontSize ?? 16
|
||||
|
||||
const position = new Vec()
|
||||
let anchor: FocusedTextAnchor = 'top-left'
|
||||
switch (shape.props.textAlign) {
|
||||
case 'middle': {
|
||||
anchor = 'top-center'
|
||||
position.x = bounds.center.x
|
||||
position.y = bounds.top
|
||||
break
|
||||
}
|
||||
case 'end': {
|
||||
anchor = 'top-right'
|
||||
position.x = bounds.right
|
||||
position.y = bounds.top
|
||||
break
|
||||
}
|
||||
case 'start': {
|
||||
anchor = 'top-left'
|
||||
position.x = bounds.left
|
||||
position.y = bounds.top
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
_type: 'text',
|
||||
anchor,
|
||||
color: shape.props.color,
|
||||
fontSize: convertTldrawFontSizeAndScaleToFocusedFontSize(
|
||||
textSize,
|
||||
shape.props.scale,
|
||||
baseFontSize
|
||||
),
|
||||
maxWidth: shape.props.autoSize ? null : shape.props.w,
|
||||
note: (shape.meta.note as string) ?? '',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
text,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
}
|
||||
}
|
||||
|
||||
function convertGeoShapeToFocused(editor: Editor, shape: TLGeoShape): FocusedGeoShape {
|
||||
const util = editor.getShapeUtil(shape)
|
||||
const text = util.getText(shape)
|
||||
const bounds = getSimpleBounds(editor, shape)
|
||||
const shapeTextAlign = shape.props.align
|
||||
|
||||
let newTextAlign: FocusedGeoShape['textAlign']
|
||||
switch (shapeTextAlign) {
|
||||
case 'start-legacy':
|
||||
newTextAlign = 'start'
|
||||
break
|
||||
case 'middle-legacy':
|
||||
newTextAlign = 'middle'
|
||||
break
|
||||
case 'end-legacy':
|
||||
newTextAlign = 'end'
|
||||
break
|
||||
default:
|
||||
newTextAlign = shapeTextAlign
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
_type: GEO_TO_FOCUSED_TYPES[shape.props.geo],
|
||||
color: shape.props.color,
|
||||
fill: convertTldrawFillToFocusedFill(shape.props.fill),
|
||||
h: shape.props.h,
|
||||
note: (shape.meta.note as string) ?? '',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
text: text ?? '',
|
||||
textAlign: newTextAlign,
|
||||
w: shape.props.w,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
}
|
||||
}
|
||||
|
||||
function convertLineShapeToFocused(editor: Editor, shape: TLLineShape): FocusedLineShape {
|
||||
const bounds = getSimpleBounds(editor, shape)
|
||||
const points = Object.values(shape.props.points).sort((a, b) => a.index.localeCompare(b.index))
|
||||
return {
|
||||
_type: 'line',
|
||||
color: shape.props.color,
|
||||
note: (shape.meta.note as string) ?? '',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
x1: points[0].x + bounds.x,
|
||||
x2: points[1].x + bounds.x,
|
||||
y1: points[0].y + bounds.y,
|
||||
y2: points[1].y + bounds.y,
|
||||
}
|
||||
}
|
||||
|
||||
function convertArrowShapeToFocused(editor: Editor, shape: TLArrowShape): FocusedArrowShape {
|
||||
const bounds = getSimpleBounds(editor, shape)
|
||||
const bindings = editor.store.query.records('binding').get()
|
||||
const arrowBindings = bindings.filter(
|
||||
(b) => b.type === 'arrow' && b.fromId === shape.id
|
||||
) as TLArrowBinding[]
|
||||
const startBinding = arrowBindings.find((b) => b.props.terminal === 'start')
|
||||
const endBinding = arrowBindings.find((b) => b.props.terminal === 'end')
|
||||
|
||||
return {
|
||||
_type: 'arrow',
|
||||
bend: shape.props.bend * -1,
|
||||
kind: shape.props.kind,
|
||||
color: shape.props.color,
|
||||
fromId: startBinding ? convertTldrawIdToSimpleId(startBinding.toId) : null,
|
||||
note: (shape.meta.note as string) ?? '',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
text: editor.getShapeUtil(shape).getText(shape) ?? '',
|
||||
toId: endBinding ? convertTldrawIdToSimpleId(endBinding.toId) : null,
|
||||
x1: shape.props.start.x + bounds.x,
|
||||
x2: shape.props.end.x + bounds.x,
|
||||
y1: shape.props.start.y + bounds.y,
|
||||
y2: shape.props.end.y + bounds.y,
|
||||
}
|
||||
}
|
||||
|
||||
function convertNoteShapeToFocused(editor: Editor, shape: TLNoteShape): FocusedNoteShape {
|
||||
const util = editor.getShapeUtil(shape)
|
||||
const text = util.getText(shape)
|
||||
const bounds = getSimpleBounds(editor, shape)
|
||||
return {
|
||||
_type: 'note',
|
||||
color: shape.props.color,
|
||||
note: (shape.meta.note as string) ?? '',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
text: text ?? '',
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
}
|
||||
}
|
||||
|
||||
function convertUnknownShapeToFocused(editor: Editor, shape: TLShape): FocusedUnknownShape {
|
||||
const bounds = getSimpleBounds(editor, shape)
|
||||
return {
|
||||
_type: 'unknown',
|
||||
note: (shape.meta.note as string) ?? '',
|
||||
shapeId: convertTldrawIdToSimpleId(shape.id),
|
||||
subType: shape.type,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
}
|
||||
}
|
||||
|
||||
function getSimpleBounds(editor: Editor, shape: TLShape): Box {
|
||||
const pagePoint = getShapePagePoint(editor, shape)
|
||||
|
||||
const props = shape.props as { w?: number; h?: number }
|
||||
if (props.w !== undefined && props.h !== undefined) {
|
||||
return new Box(pagePoint.x, pagePoint.y, props.w, props.h)
|
||||
}
|
||||
|
||||
const bounds = editor.getShapePageBounds(shape)
|
||||
if (bounds) {
|
||||
return new Box(pagePoint.x, pagePoint.y, bounds.w, bounds.h)
|
||||
}
|
||||
|
||||
let mockBounds: Box | undefined
|
||||
const diff = editor.store.extractingChanges(() => {
|
||||
editor.run(
|
||||
() => {
|
||||
const mockId = createShapeId()
|
||||
editor.createShape({ ...shape, id: mockId })
|
||||
mockBounds = editor.getShapePageBounds(mockId)
|
||||
},
|
||||
{ ignoreShapeLock: false, history: 'ignore' }
|
||||
)
|
||||
})
|
||||
const reverseDiff = reverseRecordsDiff(diff)
|
||||
editor.store.applyDiff(reverseDiff)
|
||||
|
||||
if (!mockBounds) {
|
||||
throw new Error('Failed to get bounds for shape')
|
||||
}
|
||||
return new Box(pagePoint.x, pagePoint.y, mockBounds.w, mockBounds.h)
|
||||
}
|
||||
|
||||
function getShapePagePoint(editor: Editor, shape: TLShape): Vec {
|
||||
if (isPageId(shape.parentId)) {
|
||||
return new Vec(shape.x, shape.y)
|
||||
}
|
||||
const parentTransform = editor.getShapePageTransform(shape.parentId)
|
||||
return parentTransform.applyToPoint(new Vec(shape.x, shape.y))
|
||||
}
|
||||
572
apps/mcp-app/src/widget/focused/to-tldraw.ts
Normal file
572
apps/mcp-app/src/widget/focused/to-tldraw.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Convert FocusedShape → tldraw TLShape.
|
||||
* Ported from tldraw/tldraw templates/agent/shared/format/convertFocusedShapeToTldrawShape.ts
|
||||
*/
|
||||
import {
|
||||
Box,
|
||||
createShapeId,
|
||||
Editor,
|
||||
IndexKey,
|
||||
reverseRecordsDiff,
|
||||
TLArrowShape,
|
||||
TLBindingCreate,
|
||||
TLDefaultSizeStyle,
|
||||
TLDrawShape,
|
||||
TLGeoShape,
|
||||
TLLineShape,
|
||||
TLNoteShape,
|
||||
TLShape,
|
||||
TLTextShape,
|
||||
toRichText,
|
||||
Vec,
|
||||
} from 'tldraw'
|
||||
import {
|
||||
asColor,
|
||||
convertFocusedFillToTldrawFill,
|
||||
convertFocusedFontSizeToTldrawFontSizeAndScale,
|
||||
convertSimpleIdToTldrawId,
|
||||
FOCUSED_TO_GEO_TYPES,
|
||||
type FocusedArrowShape,
|
||||
type FocusedDrawShape,
|
||||
type FocusedGeoShape,
|
||||
type FocusedGeoShapeType,
|
||||
type FocusedLineShape,
|
||||
type FocusedNoteShape,
|
||||
type FocusedShape,
|
||||
type FocusedTextShape,
|
||||
type FocusedUnknownShape,
|
||||
} from './format'
|
||||
|
||||
export function convertFocusedShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLShape; bindings?: TLBindingCreate[] } {
|
||||
switch (focusedShape._type) {
|
||||
case 'text':
|
||||
return convertTextShapeToTldrawShape(editor, focusedShape, { defaultShape })
|
||||
case 'line':
|
||||
return convertLineShapeToTldrawShape(editor, focusedShape, { defaultShape })
|
||||
case 'arrow':
|
||||
return convertArrowShapeToTldrawShape(editor, focusedShape, { defaultShape })
|
||||
case 'note':
|
||||
return convertNoteShapeToTldrawShape(editor, focusedShape, { defaultShape })
|
||||
case 'draw':
|
||||
return convertDrawShapeToTldrawShape(editor, focusedShape, { defaultShape })
|
||||
case 'unknown':
|
||||
return convertUnknownShapeToTldrawShape(editor, focusedShape, { defaultShape })
|
||||
default:
|
||||
// Geo types (rectangle, ellipse, etc.)
|
||||
return convertGeoShapeToTldrawShape(editor, focusedShape as FocusedGeoShape, {
|
||||
defaultShape,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function convertFocusedGeoTypeToTldrawGeoGeoType(type: FocusedGeoShapeType) {
|
||||
return FOCUSED_TO_GEO_TYPES[type]
|
||||
}
|
||||
|
||||
function convertTextShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedTextShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLTextShape } {
|
||||
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
|
||||
const defaultTextShape = defaultShape as TLTextShape
|
||||
const baseFontSize = editor.getTheme('default')?.fontSize ?? 16
|
||||
|
||||
let textSize: TLDefaultSizeStyle = 's'
|
||||
let scale = 1
|
||||
|
||||
if (focusedShape.fontSize) {
|
||||
const result = convertFocusedFontSizeToTldrawFontSizeAndScale(
|
||||
focusedShape.fontSize,
|
||||
baseFontSize
|
||||
)
|
||||
textSize = result.textSize
|
||||
scale = result.scale
|
||||
} else if (defaultTextShape.props?.size) {
|
||||
textSize = defaultTextShape.props.size
|
||||
scale = defaultTextShape.props.scale ?? 1
|
||||
}
|
||||
|
||||
const autoSize =
|
||||
focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null
|
||||
? false
|
||||
: (defaultTextShape.props?.autoSize ?? true)
|
||||
const font = defaultTextShape.props?.font ?? 'draw'
|
||||
|
||||
let richText
|
||||
if (focusedShape.text !== undefined) {
|
||||
richText = toRichText(focusedShape.text)
|
||||
} else if (defaultTextShape.props?.richText) {
|
||||
richText = defaultTextShape.props.richText
|
||||
} else {
|
||||
richText = toRichText('')
|
||||
}
|
||||
|
||||
let textAlign: TLTextShape['props']['textAlign'] = defaultTextShape.props?.textAlign ?? 'start'
|
||||
switch (focusedShape.anchor) {
|
||||
case 'top-left':
|
||||
case 'bottom-left':
|
||||
case 'center-left':
|
||||
textAlign = 'start'
|
||||
break
|
||||
case 'top-center':
|
||||
case 'bottom-center':
|
||||
case 'center':
|
||||
textAlign = 'middle'
|
||||
break
|
||||
case 'top-right':
|
||||
case 'bottom-right':
|
||||
case 'center-right':
|
||||
textAlign = 'end'
|
||||
break
|
||||
}
|
||||
|
||||
const unpositionedShape: TLTextShape = {
|
||||
id: shapeId,
|
||||
type: 'text',
|
||||
typeName: 'shape',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: defaultTextShape.rotation ?? 0,
|
||||
index: defaultTextShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
|
||||
parentId: defaultTextShape.parentId ?? editor.getCurrentPageId(),
|
||||
isLocked: defaultTextShape.isLocked ?? false,
|
||||
opacity: defaultTextShape.opacity ?? 1,
|
||||
props: {
|
||||
size: textSize,
|
||||
scale,
|
||||
richText,
|
||||
color: asColor(focusedShape.color ?? defaultTextShape.props?.color ?? 'black'),
|
||||
textAlign,
|
||||
autoSize,
|
||||
w:
|
||||
focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null
|
||||
? focusedShape.maxWidth
|
||||
: (defaultTextShape.props?.w ?? 100),
|
||||
font,
|
||||
},
|
||||
meta: {
|
||||
note: focusedShape.note ?? defaultTextShape.meta?.note ?? '',
|
||||
},
|
||||
}
|
||||
|
||||
const unpositionedBounds = getDummyBounds(editor, unpositionedShape)
|
||||
|
||||
const position = new Vec(defaultTextShape.x ?? 0, defaultTextShape.y ?? 0)
|
||||
const x = focusedShape.x ?? defaultTextShape.x ?? 0
|
||||
const y = focusedShape.y ?? defaultTextShape.y ?? 0
|
||||
switch (focusedShape.anchor) {
|
||||
case 'top-center': {
|
||||
position.x = x - unpositionedBounds.w / 2
|
||||
position.y = y
|
||||
break
|
||||
}
|
||||
case 'top-right': {
|
||||
position.x = x - unpositionedBounds.w
|
||||
position.y = y
|
||||
break
|
||||
}
|
||||
case 'bottom-left': {
|
||||
position.x = x
|
||||
position.y = y - unpositionedBounds.h
|
||||
break
|
||||
}
|
||||
case 'bottom-center': {
|
||||
position.x = x - unpositionedBounds.w / 2
|
||||
position.y = y - unpositionedBounds.h
|
||||
break
|
||||
}
|
||||
case 'bottom-right': {
|
||||
position.x = x - unpositionedBounds.w
|
||||
position.y = y - unpositionedBounds.h
|
||||
break
|
||||
}
|
||||
case 'center-left': {
|
||||
position.x = x
|
||||
position.y = y - unpositionedBounds.h / 2
|
||||
break
|
||||
}
|
||||
case 'center-right': {
|
||||
position.x = x - unpositionedBounds.w
|
||||
position.y = y - unpositionedBounds.h / 2
|
||||
break
|
||||
}
|
||||
case 'center': {
|
||||
position.x = focusedShape.x - unpositionedBounds.w / 2
|
||||
position.y = focusedShape.y - unpositionedBounds.h / 2
|
||||
break
|
||||
}
|
||||
case 'top-left':
|
||||
default: {
|
||||
position.x = x
|
||||
position.y = y
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shape: { ...unpositionedShape, x: position.x, y: position.y },
|
||||
}
|
||||
}
|
||||
|
||||
function convertLineShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedLineShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLShape } {
|
||||
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
|
||||
const defaultLineShape = defaultShape as TLLineShape
|
||||
|
||||
const x1 = focusedShape.x1 ?? 0
|
||||
const y1 = focusedShape.y1 ?? 0
|
||||
const x2 = focusedShape.x2 ?? 0
|
||||
const y2 = focusedShape.y2 ?? 0
|
||||
const minX = Math.min(x1, x2)
|
||||
const minY = Math.min(y1, y2)
|
||||
|
||||
return {
|
||||
shape: {
|
||||
id: shapeId,
|
||||
type: 'line',
|
||||
typeName: 'shape',
|
||||
x: minX,
|
||||
y: minY,
|
||||
rotation: defaultLineShape.rotation ?? 0,
|
||||
index: defaultLineShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
|
||||
parentId: defaultLineShape.parentId ?? editor.getCurrentPageId(),
|
||||
isLocked: defaultLineShape.isLocked ?? false,
|
||||
opacity: defaultLineShape.opacity ?? 1,
|
||||
props: {
|
||||
size: defaultLineShape.props?.size ?? 's',
|
||||
points: {
|
||||
a1: { id: 'a1', index: 'a1' as IndexKey, x: x1 - minX, y: y1 - minY },
|
||||
a2: { id: 'a2', index: 'a2' as IndexKey, x: x2 - minX, y: y2 - minY },
|
||||
},
|
||||
color: asColor(focusedShape.color ?? defaultLineShape.props?.color ?? 'black'),
|
||||
dash: defaultLineShape.props?.dash ?? 'draw',
|
||||
scale: defaultLineShape.props?.scale ?? 1,
|
||||
spline: defaultLineShape.props?.spline ?? 'line',
|
||||
},
|
||||
meta: {
|
||||
note: focusedShape.note ?? defaultLineShape.meta?.note ?? '',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function convertArrowShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedArrowShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLShape; bindings?: TLBindingCreate[] } {
|
||||
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
|
||||
const defaultArrowShape = defaultShape as TLArrowShape
|
||||
|
||||
const x1 = focusedShape.x1 ?? defaultArrowShape.props?.start?.x ?? 0
|
||||
const y1 = focusedShape.y1 ?? defaultArrowShape.props?.start?.y ?? 0
|
||||
const x2 = focusedShape.x2 ?? defaultArrowShape.props?.end?.x ?? 0
|
||||
const y2 = focusedShape.y2 ?? defaultArrowShape.props?.end?.y ?? 0
|
||||
const minX = Math.min(x1, x2)
|
||||
const minY = Math.min(y1, y2)
|
||||
|
||||
let richText
|
||||
if (focusedShape.text !== undefined) {
|
||||
richText = toRichText(focusedShape.text)
|
||||
} else if (defaultArrowShape.props?.richText) {
|
||||
richText = defaultArrowShape.props.richText
|
||||
} else {
|
||||
richText = toRichText('')
|
||||
}
|
||||
|
||||
const shape = {
|
||||
id: shapeId,
|
||||
type: 'arrow' as const,
|
||||
typeName: 'shape' as const,
|
||||
x: minX,
|
||||
y: minY,
|
||||
rotation: defaultArrowShape.rotation ?? 0,
|
||||
index: defaultArrowShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
|
||||
parentId: defaultArrowShape.parentId ?? editor.getCurrentPageId(),
|
||||
isLocked: defaultArrowShape.isLocked ?? false,
|
||||
opacity: defaultArrowShape.opacity ?? 1,
|
||||
props: {
|
||||
arrowheadEnd: defaultArrowShape.props?.arrowheadEnd ?? 'arrow',
|
||||
arrowheadStart: defaultArrowShape.props?.arrowheadStart ?? 'none',
|
||||
bend: (focusedShape.bend ?? (defaultArrowShape.props?.bend ?? 0) * -1) * -1,
|
||||
color: asColor(focusedShape.color ?? defaultArrowShape.props?.color ?? 'black'),
|
||||
dash: defaultArrowShape.props?.dash ?? 'draw',
|
||||
elbowMidPoint: defaultArrowShape.props?.elbowMidPoint ?? 0.5,
|
||||
end: { x: x2 - minX, y: y2 - minY },
|
||||
fill: defaultArrowShape.props?.fill ?? 'none',
|
||||
font: defaultArrowShape.props?.font ?? 'draw',
|
||||
kind: focusedShape.kind ?? defaultArrowShape.props?.kind ?? 'arc',
|
||||
labelColor: defaultArrowShape.props?.labelColor ?? 'black',
|
||||
labelPosition: defaultArrowShape.props?.labelPosition ?? 0.5,
|
||||
richText,
|
||||
scale: defaultArrowShape.props?.scale ?? 1,
|
||||
size: defaultArrowShape.props?.size ?? 's',
|
||||
start: { x: x1 - minX, y: y1 - minY },
|
||||
},
|
||||
meta: {
|
||||
note: focusedShape.note ?? defaultArrowShape.meta?.note ?? '',
|
||||
},
|
||||
}
|
||||
|
||||
const bindings: TLBindingCreate[] = []
|
||||
|
||||
if (focusedShape.fromId) {
|
||||
const fromId = convertSimpleIdToTldrawId(focusedShape.fromId)
|
||||
const startShape = editor.getShape(fromId)
|
||||
if (startShape) {
|
||||
bindings.push({
|
||||
type: 'arrow',
|
||||
typeName: 'binding',
|
||||
fromId: shapeId,
|
||||
toId: startShape.id,
|
||||
props: {
|
||||
normalizedAnchor: { x: 0.5, y: 0.5 },
|
||||
isExact: false,
|
||||
isPrecise: false,
|
||||
terminal: 'start',
|
||||
},
|
||||
meta: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (focusedShape.toId) {
|
||||
const toId = convertSimpleIdToTldrawId(focusedShape.toId)
|
||||
const endShape = editor.getShape(toId)
|
||||
if (endShape) {
|
||||
bindings.push({
|
||||
type: 'arrow',
|
||||
typeName: 'binding',
|
||||
fromId: shapeId,
|
||||
toId: endShape.id,
|
||||
props: {
|
||||
normalizedAnchor: { x: 0.5, y: 0.5 },
|
||||
isExact: false,
|
||||
isPrecise: false,
|
||||
terminal: 'end',
|
||||
},
|
||||
meta: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shape,
|
||||
bindings: bindings.length > 0 ? bindings : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function convertGeoShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedGeoShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLShape } {
|
||||
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
|
||||
const shapeType = convertFocusedGeoTypeToTldrawGeoGeoType(focusedShape._type)
|
||||
const defaultGeoShape = defaultShape as TLGeoShape
|
||||
|
||||
let richText
|
||||
if (focusedShape.text !== undefined) {
|
||||
richText = toRichText(focusedShape.text)
|
||||
} else if (defaultGeoShape.props?.richText) {
|
||||
richText = defaultGeoShape.props.richText
|
||||
} else {
|
||||
richText = toRichText('')
|
||||
}
|
||||
|
||||
let fill
|
||||
if (focusedShape.fill !== undefined) {
|
||||
fill = convertFocusedFillToTldrawFill(focusedShape.fill) ?? 'none'
|
||||
} else if (defaultGeoShape.props?.fill) {
|
||||
fill = defaultGeoShape.props.fill
|
||||
} else {
|
||||
fill = convertFocusedFillToTldrawFill('none')
|
||||
}
|
||||
|
||||
return {
|
||||
shape: {
|
||||
id: shapeId,
|
||||
type: 'geo',
|
||||
typeName: 'shape',
|
||||
x: focusedShape.x ?? defaultGeoShape.x ?? 0,
|
||||
y: focusedShape.y ?? defaultGeoShape.y ?? 0,
|
||||
rotation: defaultGeoShape.rotation ?? 0,
|
||||
index: defaultGeoShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
|
||||
parentId: defaultGeoShape.parentId ?? editor.getCurrentPageId(),
|
||||
isLocked: defaultGeoShape.isLocked ?? false,
|
||||
opacity: defaultGeoShape.opacity ?? 1,
|
||||
props: {
|
||||
align: focusedShape.textAlign ?? defaultGeoShape.props?.align ?? 'start',
|
||||
color: asColor(focusedShape.color ?? defaultGeoShape.props?.color ?? 'black'),
|
||||
dash: defaultGeoShape.props?.dash ?? 'draw',
|
||||
fill,
|
||||
font: defaultGeoShape.props?.font ?? 'draw',
|
||||
geo: shapeType,
|
||||
growY: defaultGeoShape.props?.growY ?? 0,
|
||||
h: focusedShape.h ?? defaultGeoShape.props?.h ?? 100,
|
||||
labelColor: defaultGeoShape.props?.labelColor ?? 'black',
|
||||
richText,
|
||||
scale: defaultGeoShape.props?.scale ?? 1,
|
||||
size: defaultGeoShape.props?.size ?? 's',
|
||||
url: defaultGeoShape.props?.url ?? '',
|
||||
verticalAlign: defaultGeoShape.props?.verticalAlign ?? 'start',
|
||||
w: focusedShape.w ?? defaultGeoShape.props?.w ?? 100,
|
||||
flipX: defaultGeoShape.props?.flipX ?? false,
|
||||
flipY: defaultGeoShape.props?.flipY ?? false,
|
||||
},
|
||||
meta: {
|
||||
note: focusedShape.note ?? defaultGeoShape.meta?.note ?? '',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function convertNoteShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedNoteShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLShape } {
|
||||
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
|
||||
const defaultNoteShape = defaultShape as TLNoteShape
|
||||
|
||||
let richText
|
||||
if (focusedShape.text !== undefined) {
|
||||
richText = toRichText(focusedShape.text)
|
||||
} else if (defaultNoteShape.props?.richText) {
|
||||
richText = defaultNoteShape.props.richText
|
||||
} else {
|
||||
richText = toRichText('')
|
||||
}
|
||||
|
||||
return {
|
||||
shape: {
|
||||
id: shapeId,
|
||||
type: 'note',
|
||||
typeName: 'shape',
|
||||
x: focusedShape.x ?? defaultNoteShape.x ?? 0,
|
||||
y: focusedShape.y ?? defaultNoteShape.y ?? 0,
|
||||
rotation: defaultNoteShape.rotation ?? 0,
|
||||
index: defaultNoteShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
|
||||
parentId: defaultNoteShape.parentId ?? editor.getCurrentPageId(),
|
||||
isLocked: defaultNoteShape.isLocked ?? false,
|
||||
opacity: defaultNoteShape.opacity ?? 1,
|
||||
props: {
|
||||
color: asColor(focusedShape.color ?? defaultNoteShape.props?.color ?? 'black'),
|
||||
richText,
|
||||
size: defaultNoteShape.props?.size ?? 's',
|
||||
align: defaultNoteShape.props?.align ?? 'middle',
|
||||
font: defaultNoteShape.props?.font ?? 'draw',
|
||||
fontSizeAdjustment: defaultNoteShape.props?.fontSizeAdjustment ?? 0,
|
||||
growY: defaultNoteShape.props?.growY ?? 0,
|
||||
labelColor: defaultNoteShape.props?.labelColor ?? 'black',
|
||||
scale: defaultNoteShape.props?.scale ?? 1,
|
||||
url: defaultNoteShape.props?.url ?? '',
|
||||
verticalAlign: defaultNoteShape.props?.verticalAlign ?? 'middle',
|
||||
textLastEditedBy: defaultNoteShape.props?.textLastEditedBy ?? null,
|
||||
},
|
||||
meta: {
|
||||
note: focusedShape.note ?? defaultNoteShape.meta?.note ?? '',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function convertDrawShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedDrawShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLShape } {
|
||||
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
|
||||
const defaultDrawShape = defaultShape as TLDrawShape
|
||||
|
||||
let fill
|
||||
if (focusedShape.fill !== undefined) {
|
||||
fill = convertFocusedFillToTldrawFill(focusedShape.fill)
|
||||
} else if (defaultDrawShape.props?.fill) {
|
||||
fill = defaultDrawShape.props.fill
|
||||
} else {
|
||||
fill = convertFocusedFillToTldrawFill('none')
|
||||
}
|
||||
|
||||
return {
|
||||
shape: {
|
||||
id: shapeId,
|
||||
type: 'draw',
|
||||
typeName: 'shape',
|
||||
x: defaultDrawShape.x ?? 0,
|
||||
y: defaultDrawShape.y ?? 0,
|
||||
rotation: defaultDrawShape.rotation ?? 0,
|
||||
index: defaultDrawShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
|
||||
parentId: defaultDrawShape.parentId ?? editor.getCurrentPageId(),
|
||||
isLocked: defaultDrawShape.isLocked ?? false,
|
||||
opacity: defaultDrawShape.opacity ?? 1,
|
||||
props: {
|
||||
...editor.getShapeUtil('draw').getDefaultProps(),
|
||||
color: asColor(focusedShape.color ?? defaultDrawShape.props?.color ?? 'black'),
|
||||
fill,
|
||||
},
|
||||
meta: {
|
||||
note: focusedShape.note ?? defaultDrawShape.meta?.note ?? '',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function convertUnknownShapeToTldrawShape(
|
||||
editor: Editor,
|
||||
focusedShape: FocusedUnknownShape,
|
||||
{ defaultShape }: { defaultShape: Partial<TLShape> }
|
||||
): { shape: TLShape } {
|
||||
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
|
||||
|
||||
return {
|
||||
shape: {
|
||||
id: shapeId,
|
||||
type: defaultShape.type ?? 'geo',
|
||||
typeName: 'shape',
|
||||
x: focusedShape.x ?? defaultShape.x ?? 0,
|
||||
y: focusedShape.y ?? defaultShape.y ?? 0,
|
||||
rotation: defaultShape.rotation ?? 0,
|
||||
index: defaultShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
|
||||
parentId: defaultShape.parentId ?? editor.getCurrentPageId(),
|
||||
isLocked: defaultShape.isLocked ?? false,
|
||||
opacity: defaultShape.opacity ?? 1,
|
||||
props: defaultShape.props ?? ({} as any),
|
||||
meta: {
|
||||
note: focusedShape.note ?? defaultShape.meta?.note ?? '',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getDummyBounds(editor: Editor, shape: TLShape): Box {
|
||||
const bounds = editor.getShapePageBounds(shape)
|
||||
if (bounds) return bounds
|
||||
|
||||
let dummyBounds: Box | undefined
|
||||
const diff = editor.store.extractingChanges(() => {
|
||||
editor.run(
|
||||
() => {
|
||||
const dummyId = createShapeId()
|
||||
editor.createShape({ ...shape, id: dummyId })
|
||||
dummyBounds = editor.getShapePageBounds(dummyId)
|
||||
},
|
||||
{ ignoreShapeLock: false, history: 'ignore' }
|
||||
)
|
||||
})
|
||||
const reverseDiff = reverseRecordsDiff(diff)
|
||||
editor.store.applyDiff(reverseDiff)
|
||||
|
||||
if (!dummyBounds) {
|
||||
throw new Error('Failed to get bounds for shape')
|
||||
}
|
||||
return dummyBounds
|
||||
}
|
||||
106
apps/mcp-app/src/widget/image-guard.tsx
Normal file
106
apps/mcp-app/src/widget/image-guard.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect } from 'react'
|
||||
import { type TLUiOverrides, useEditor, useToasts } from 'tldraw'
|
||||
|
||||
function extractImageFiles(data: DataTransfer | null): File[] {
|
||||
if (!data) return []
|
||||
const result: File[] = []
|
||||
for (const item of data.items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile()
|
||||
if (file) result.push(file)
|
||||
}
|
||||
}
|
||||
if (result.length > 0) return result
|
||||
return [...data.files].filter((f) => f.type.startsWith('image/'))
|
||||
}
|
||||
|
||||
const COMING_SOON_TOAST = {
|
||||
id: 'feature-coming-soon',
|
||||
title: 'Coming soon',
|
||||
description: 'This feature is coming soon!',
|
||||
severity: 'info' as const,
|
||||
}
|
||||
|
||||
/** Intercepts image drop/paste and shows a "coming soon" toast. */
|
||||
export function ImageDropGuard() {
|
||||
const editor = useEditor()
|
||||
const { addToast } = useToasts()
|
||||
|
||||
useEffect(() => {
|
||||
const container = editor.getContainer()
|
||||
|
||||
const showBlockedToast = (type: string) => {
|
||||
addToast({
|
||||
id: `blocked-${type}`,
|
||||
title: 'Coming soon!',
|
||||
description: `${type} are not yet supported in the tldraw MCP app.`,
|
||||
severity: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const imageFiles = extractImageFiles(e.dataTransfer)
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
showBlockedToast('Images')
|
||||
}
|
||||
}
|
||||
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const imageFiles = extractImageFiles(e.clipboardData)
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
showBlockedToast('Images')
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('drop', onDrop, { capture: true })
|
||||
document.addEventListener('paste', onPaste, { capture: true })
|
||||
|
||||
// Override external content handlers to block images, embeds, and URLs.
|
||||
// The context menu paste uses navigator.clipboard.read() directly,
|
||||
// bypassing DOM paste events, so we need to intercept at this level too.
|
||||
editor.registerExternalContentHandler('files', async ({ files }) => {
|
||||
const hasImages = files.some((f) => f.type.startsWith('image/'))
|
||||
if (hasImages) {
|
||||
showBlockedToast('Images')
|
||||
}
|
||||
})
|
||||
editor.registerExternalContentHandler('embed', async () => {
|
||||
showBlockedToast('Embeds')
|
||||
})
|
||||
editor.registerExternalContentHandler('url', async () => {
|
||||
showBlockedToast('Links')
|
||||
})
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('drop', onDrop, { capture: true })
|
||||
document.removeEventListener('paste', onPaste, { capture: true })
|
||||
}
|
||||
}, [editor, addToast])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Override actions/tools to block media, embeds, and flatten. */
|
||||
export const uiOverrides: TLUiOverrides = {
|
||||
actions(_editor, actions, helpers) {
|
||||
const { 'insert-media': _media, 'insert-embed': _embed, ...rest } = actions
|
||||
return {
|
||||
...rest,
|
||||
'flatten-to-image': {
|
||||
...actions['flatten-to-image'],
|
||||
onSelect() {
|
||||
helpers.addToast(COMING_SOON_TOAST)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
tools(_editor, tools) {
|
||||
// Remove the asset tool (image/media picker from toolbar)
|
||||
const { asset: _asset, ...rest } = tools
|
||||
return rest
|
||||
},
|
||||
}
|
||||
33
apps/mcp-app/src/widget/index.html
Normal file
33
apps/mcp-app/src/widget/index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>tldraw MCP</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./mcp-app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
111
apps/mcp-app/src/widget/mcp-app.css
Normal file
111
apps/mcp-app/src/widget/mcp-app.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.mcp-app__share-zone {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mcp-app__build-button {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
background: var(--tl-color-muted-2);
|
||||
color: var(--tl-color-muted-1);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: var(--tl-space-3) var(--tl-space-4);
|
||||
border-radius: var(--tl-radius-2);
|
||||
margin: var(--tl-space-2);
|
||||
cursor: not-allowed;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.mcp-app__build-button--enabled {
|
||||
background: var(--tl-color-primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mcp-app__error-banner {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin-top: -1px;
|
||||
gap: 10px;
|
||||
color: #2e2e2e;
|
||||
}
|
||||
|
||||
.mcp-app__error-banner--dark {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.mcp-app__error-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
filter: brightness(0) saturate(100%);
|
||||
}
|
||||
|
||||
.mcp-app__error-logo--dark {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.mcp-app__error-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mcp-app__error-message {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #5f5f5f;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.mcp-app__error-message--dark {
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.mcp-app__canvas-layout {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 600px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mcp-app__canvas-layout--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: var(--tl-color-background);
|
||||
}
|
||||
|
||||
.mcp-app__canvas-surface {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mcp-app__canvas-surface--with-dev-log {
|
||||
height: calc(600px - 140px - 8px);
|
||||
}
|
||||
|
||||
.mcp-app__canvas-surface--fullscreen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mcp-app__status {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mcp-app__status--error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.mcp-app__status--connecting {
|
||||
opacity: 0.5;
|
||||
}
|
||||
871
apps/mcp-app/src/widget/mcp-app.tsx
Normal file
871
apps/mcp-app/src/widget/mcp-app.tsx
Normal file
@@ -0,0 +1,871 @@
|
||||
import { type App, McpUiDisplayMode, useApp } from '@modelcontextprotocol/ext-apps/react'
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import {
|
||||
type TLAsset,
|
||||
type TLBindingCreate,
|
||||
type TLComponents,
|
||||
type TLShapeId,
|
||||
type TLUiEventHandler,
|
||||
DefaultToolbar,
|
||||
DefaultToolbarContent,
|
||||
Editor,
|
||||
Tldraw,
|
||||
TldrawUiIcon,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
import './mcp-app.css'
|
||||
import tldrawLogoUrl from '../../plugins/tldraw-mcp/assets/logo.svg'
|
||||
import { computeExecKey } from '../shared/exec-key'
|
||||
import { primeEmbeddedMethodMap } from '../shared/generated-data'
|
||||
import {
|
||||
MCP_SERVER_DESCRIPTION,
|
||||
MCP_SERVER_NAME,
|
||||
MCP_SERVER_TITLE,
|
||||
MCP_SERVER_VERSION,
|
||||
MCP_SERVER_WEBSITE_URL,
|
||||
} from '../shared/types'
|
||||
import type { MCP_APP_HOST_NAMES } from '../shared/types'
|
||||
import { isHostCodeEditor, resolveMcpAppHostNameFromClientInfo } from '../shared/utils'
|
||||
import { McpAppContext } from './app-context'
|
||||
import { DEV_LOG_PANEL_HEIGHT, DevLogPanel, useDevLog } from './dev-log'
|
||||
import { executeCode } from './exec-helpers'
|
||||
import { exportTldr } from './export-tldr'
|
||||
import { ImageDropGuard, uiOverrides } from './image-guard'
|
||||
import {
|
||||
type CanvasSnapshot,
|
||||
getCurrentCanvasId,
|
||||
getEmbeddedBootstrap,
|
||||
getLatestCheckpointSnapshot,
|
||||
loadLocalSnapshot,
|
||||
parseCheckpointFromToolResult,
|
||||
clearCanvasContext,
|
||||
pushCanvasContext,
|
||||
saveCheckpointToServer,
|
||||
saveLocalSnapshot,
|
||||
setCurrentCanvasId,
|
||||
setCurrentSessionId,
|
||||
syncEditorState,
|
||||
} from './persistence'
|
||||
import { applySnapshot, zoomToFitRequestShapes } from './snapshot'
|
||||
|
||||
const LICENSE_KEY = import.meta.env.VITE_TLDRAW_LICENSE_KEY as string
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
function SharePanelContent() {
|
||||
const editor = useEditor()
|
||||
const hasShapes = useValue('hasShapes', () => editor.getCurrentPageShapeIds().size > 0, [editor])
|
||||
|
||||
const {
|
||||
displayMode,
|
||||
toggleFullscreen,
|
||||
canFullscreen,
|
||||
canDownload,
|
||||
app,
|
||||
lastEditor,
|
||||
hostName,
|
||||
isDev,
|
||||
isDevLogVisible,
|
||||
toggleDevLog,
|
||||
} = useContext(McpAppContext)
|
||||
|
||||
const isCodeEditor = useMemo(() => {
|
||||
if (!hostName) return false
|
||||
return isHostCodeEditor(hostName)
|
||||
}, [hostName])
|
||||
|
||||
const handleBuildItClick = useCallback(() => {
|
||||
if (!app) return
|
||||
const messageText =
|
||||
lastEditor === 'user'
|
||||
? "Hey I've made some edits to the canvas. The new canvas state is attached. Take the changes and implement them in the codebase."
|
||||
: 'Use the attached canvas state to implement this in the codebase.'
|
||||
app.sendMessage({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: messageText,
|
||||
},
|
||||
],
|
||||
})
|
||||
}, [app, lastEditor])
|
||||
|
||||
return (
|
||||
<div className="tlui-share-zone mcp-app__share-zone" draggable={false}>
|
||||
{canDownload && (
|
||||
<button
|
||||
className="tlui-button tlui-button__low"
|
||||
onClick={() => exportTldr(editor, app ?? undefined)}
|
||||
title="Copy to clipboard and download .tldr file"
|
||||
>
|
||||
<TldrawUiIcon label="Download .tldr file" icon="download" />
|
||||
</button>
|
||||
)}
|
||||
{toggleFullscreen && canFullscreen && (
|
||||
<button
|
||||
className="tlui-button tlui-button__low"
|
||||
onClick={toggleFullscreen}
|
||||
title={displayMode === 'fullscreen' ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{displayMode === 'fullscreen' ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
</button>
|
||||
)}
|
||||
{isDev && toggleDevLog && (
|
||||
<button
|
||||
className="tlui-button tlui-button__low"
|
||||
onClick={toggleDevLog}
|
||||
title={isDevLogVisible ? 'Hide dev log' : 'Show dev log'}
|
||||
>
|
||||
{isDevLogVisible ? 'Hide dev log' : 'Show dev log'}
|
||||
</button>
|
||||
)}
|
||||
{isCodeEditor && app && (
|
||||
<button
|
||||
onClick={handleBuildItClick}
|
||||
disabled={!hasShapes}
|
||||
title="Build it"
|
||||
className={`mcp-app__build-button${hasShapes ? ' mcp-app__build-button--enabled' : ''}`}
|
||||
>
|
||||
Build it
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicToolbar() {
|
||||
const { displayMode } = useContext(McpAppContext)
|
||||
return (
|
||||
<DefaultToolbar orientation={displayMode === 'fullscreen' ? 'vertical' : 'horizontal'}>
|
||||
<DefaultToolbarContent />
|
||||
</DefaultToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
const tldrawComponents: TLComponents = {
|
||||
SharePanel: SharePanelContent,
|
||||
Toolbar: DynamicToolbar,
|
||||
}
|
||||
|
||||
const ERROR_BANNER_HEIGHT = 30
|
||||
|
||||
function parseHostTheme(value: unknown): 'light' | 'dark' | null {
|
||||
return value === 'dark' || value === 'light' ? value : null
|
||||
}
|
||||
|
||||
function TldrawCanvas({ app }: { app: App }) {
|
||||
const [displayMode, setDisplayMode] =
|
||||
useState<Extract<McpUiDisplayMode, 'inline' | 'fullscreen'>>('inline')
|
||||
const [containerHeight, setContainerHeight] = useState<number | null>(null)
|
||||
const [lastEditor, setLastEditor] = useState<'user' | 'ai'>('ai')
|
||||
const [hostContext, setHostContext] = useState(() => app.getHostContext())
|
||||
const [hostTheme, setHostTheme] = useState<'light' | 'dark'>(() => {
|
||||
const initialTheme = parseHostTheme(
|
||||
(app.getHostContext() as { theme?: string } | undefined)?.theme
|
||||
)
|
||||
return initialTheme ?? 'light'
|
||||
})
|
||||
const [canvasTheme, setCanvasTheme] = useState<'light' | 'dark'>(hostTheme)
|
||||
const [execError, setExecError] = useState<string | null>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
|
||||
const pendingSnapshotRef = useRef<CanvasSnapshot | null>(null)
|
||||
const committedSnapshotRef = useRef<CanvasSnapshot>({ shapes: [], assets: [] })
|
||||
const checkpointIdRef = useRef<string | null>(null)
|
||||
const removeStoreListenerRef = useRef<(() => void) | null>(null)
|
||||
const editorReadyResolveRef = useRef<((editor: Editor) => void) | null>(null)
|
||||
const editorReadyPromiseRef = useRef<Promise<Editor> | null>(null)
|
||||
const saveTimerRef = useRef<number | null>(null)
|
||||
const hasUserEditedSinceAiRef = useRef(false)
|
||||
const lastEditorRef = useRef<'user' | 'ai'>('ai')
|
||||
const execPartialDebounceRef = useRef<number | null>(null)
|
||||
const hasExecRunRef = useRef(false)
|
||||
|
||||
const { isDev, isDevLogVisible, devLogEntries, logIfDevMode, toggleDevLog, enableDevMode } =
|
||||
useDevLog()
|
||||
|
||||
const hostCapabilities = useMemo(() => {
|
||||
return app.getHostCapabilities()
|
||||
}, [app])
|
||||
|
||||
const hostInfo = useMemo(() => {
|
||||
return app.getHostVersion()
|
||||
}, [app])
|
||||
|
||||
const isMobilePlatform = hostContext?.platform === 'mobile'
|
||||
const isDarkTheme = canvasTheme === 'dark'
|
||||
|
||||
const syncThemeFromEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
setCanvasTheme(editor.user.getIsDarkMode() ? 'dark' : 'light')
|
||||
}, [])
|
||||
|
||||
const applyHostThemeToEditor = useCallback((theme: 'light' | 'dark') => {
|
||||
setHostTheme(theme)
|
||||
setCanvasTheme(theme)
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.user.updateUserPreferences({ colorScheme: theme })
|
||||
}, [])
|
||||
|
||||
const handleUiEvent = useCallback<TLUiEventHandler>(
|
||||
(name) => {
|
||||
const eventName = name as string
|
||||
if (eventName !== 'toggle-dark-mode' && eventName !== 'color-scheme') return
|
||||
queueMicrotask(() => {
|
||||
syncThemeFromEditor()
|
||||
})
|
||||
},
|
||||
[syncThemeFromEditor]
|
||||
)
|
||||
|
||||
const canFullscreen = useMemo(() => {
|
||||
if (isMobilePlatform) return false
|
||||
const modes = hostContext?.availableDisplayModes
|
||||
if (!modes) return false
|
||||
return modes.includes('fullscreen')
|
||||
}, [hostContext, isMobilePlatform])
|
||||
|
||||
const canDownload = useMemo(() => {
|
||||
return !!hostCapabilities?.downloadFile
|
||||
}, [hostCapabilities])
|
||||
|
||||
const [hostName, setHostName] = useState<MCP_APP_HOST_NAMES | null>(null)
|
||||
const devLogPanelHeight = isDev && isDevLogVisible ? DEV_LOG_PANEL_HEIGHT : 0
|
||||
|
||||
useEffect(() => {
|
||||
const resolved = resolveMcpAppHostNameFromClientInfo(hostInfo?.name ?? '')
|
||||
if (resolved) {
|
||||
setHostName(resolved)
|
||||
}
|
||||
}, [hostInfo])
|
||||
|
||||
const teardownEditor = useCallback(() => {
|
||||
removeStoreListenerRef.current?.()
|
||||
removeStoreListenerRef.current = null
|
||||
if (saveTimerRef.current !== null) {
|
||||
window.clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
if (execPartialDebounceRef.current !== null) {
|
||||
window.clearTimeout(execPartialDebounceRef.current)
|
||||
execPartialDebounceRef.current = null
|
||||
}
|
||||
editorRef.current?.dispose()
|
||||
editorRef.current = null
|
||||
}, [])
|
||||
|
||||
const markAiActivity = useCallback(() => {
|
||||
hasUserEditedSinceAiRef.current = false
|
||||
if (lastEditorRef.current !== 'ai') {
|
||||
lastEditorRef.current = 'ai'
|
||||
setLastEditor('ai')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const markUserEdit = useCallback(() => {
|
||||
hasUserEditedSinceAiRef.current = true
|
||||
if (lastEditorRef.current !== 'user') {
|
||||
lastEditorRef.current = 'user'
|
||||
setLastEditor('user')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
const newMode = displayMode === 'fullscreen' ? 'inline' : 'fullscreen'
|
||||
if (newMode === 'fullscreen' && (isMobilePlatform || !canFullscreen)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newMode === 'inline') {
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
committedSnapshotRef.current = syncEditorState(app, editor, checkpointIdRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await app.requestDisplayMode({ mode: newMode })
|
||||
const actualMode = result.mode === 'fullscreen' ? 'fullscreen' : 'inline'
|
||||
setDisplayMode(actualMode)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}, [app, canFullscreen, displayMode, isMobilePlatform])
|
||||
|
||||
const mcpAppCtx = useMemo(
|
||||
() => ({
|
||||
displayMode,
|
||||
toggleFullscreen,
|
||||
canFullscreen,
|
||||
canDownload,
|
||||
app,
|
||||
lastEditor,
|
||||
hostName,
|
||||
isDev,
|
||||
isDevLogVisible,
|
||||
toggleDevLog,
|
||||
logIfDevMode,
|
||||
}),
|
||||
[
|
||||
displayMode,
|
||||
toggleFullscreen,
|
||||
canFullscreen,
|
||||
canDownload,
|
||||
app,
|
||||
lastEditor,
|
||||
hostName,
|
||||
isDev,
|
||||
isDevLogVisible,
|
||||
toggleDevLog,
|
||||
logIfDevMode,
|
||||
]
|
||||
)
|
||||
|
||||
/** Returns the editor, waiting for mount if it hasn't happened yet. */
|
||||
const waitForEditor = useCallback((): Promise<Editor> => {
|
||||
const editor = editorRef.current
|
||||
if (editor) return Promise.resolve(editor)
|
||||
|
||||
if (editorReadyPromiseRef.current) return editorReadyPromiseRef.current
|
||||
|
||||
editorReadyPromiseRef.current = new Promise<Editor>((resolve) => {
|
||||
editorReadyResolveRef.current = resolve
|
||||
})
|
||||
return editorReadyPromiseRef.current
|
||||
}, [])
|
||||
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
window.clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = null
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
syncEditorState(app, editor, checkpointIdRef.current)
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
}, [app])
|
||||
|
||||
useEffect(() => {
|
||||
setHostContext(app.getHostContext())
|
||||
const initialTheme = parseHostTheme(
|
||||
(app.getHostContext() as { theme?: string } | undefined)?.theme
|
||||
)
|
||||
if (initialTheme) {
|
||||
applyHostThemeToEditor(initialTheme)
|
||||
}
|
||||
|
||||
logIfDevMode('Bootstrap loading...')
|
||||
const bootstrap = getEmbeddedBootstrap()
|
||||
primeEmbeddedMethodMap()
|
||||
|
||||
// Delete the bootstrap data from the window object to prevent it from being used again.
|
||||
delete window.__TLDRAW_BOOTSTRAP__
|
||||
|
||||
if (bootstrap) {
|
||||
setCurrentSessionId(bootstrap.sessionId)
|
||||
if (bootstrap.canvasId) {
|
||||
setCurrentCanvasId(bootstrap.canvasId)
|
||||
}
|
||||
if (bootstrap.isDev) {
|
||||
enableDevMode()
|
||||
}
|
||||
logIfDevMode(
|
||||
`Bootstrap loaded for session ${bootstrap.sessionId}, canvas ${bootstrap.canvasId ?? 'none'}${bootstrap.isDev ? ' (dev mode)' : ''}`
|
||||
)
|
||||
|
||||
if (bootstrap.snapshot) {
|
||||
if (committedSnapshotRef.current.shapes.length === 0) {
|
||||
const snapshot: CanvasSnapshot = {
|
||||
shapes: bootstrap.snapshot.shapes,
|
||||
assets: bootstrap.snapshot.assets,
|
||||
bindings: bootstrap.snapshot.bindings,
|
||||
}
|
||||
committedSnapshotRef.current = snapshot
|
||||
if (bootstrap.checkpointId) {
|
||||
checkpointIdRef.current = bootstrap.checkpointId
|
||||
logIfDevMode(
|
||||
`Restored embedded checkpoint ${bootstrap.checkpointId} with ${bootstrap.snapshot.shapes.length} shape(s)`
|
||||
)
|
||||
}
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
applySnapshot(editor, snapshot)
|
||||
} else {
|
||||
pendingSnapshotRef.current = snapshot
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const latestSnapshot = getLatestCheckpointSnapshot()
|
||||
if (latestSnapshot && committedSnapshotRef.current.shapes.length === 0) {
|
||||
logIfDevMode(
|
||||
`Restored latest local snapshot with ${latestSnapshot.shapes.length} shape(s)`
|
||||
)
|
||||
committedSnapshotRef.current = latestSnapshot
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
applySnapshot(editor, latestSnapshot)
|
||||
} else {
|
||||
pendingSnapshotRef.current = latestSnapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.onhostcontextchanged = (ctx) => {
|
||||
const nextContext = app.getHostContext() ?? ctx
|
||||
setHostContext(nextContext)
|
||||
const nextTheme = parseHostTheme(
|
||||
(ctx as { theme?: string } | undefined)?.theme ??
|
||||
(nextContext as { theme?: string } | undefined)?.theme
|
||||
)
|
||||
if (nextTheme) {
|
||||
// Host theme changes take precedence over local preference changes.
|
||||
applyHostThemeToEditor(nextTheme)
|
||||
}
|
||||
|
||||
const dims = ctx.containerDimensions
|
||||
if (dims && 'height' in dims) {
|
||||
setContainerHeight(dims.height)
|
||||
}
|
||||
|
||||
if (ctx.displayMode !== undefined) {
|
||||
const newMode = ctx.displayMode === 'fullscreen' ? 'fullscreen' : 'inline'
|
||||
|
||||
if (newMode !== 'fullscreen') {
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
committedSnapshotRef.current = syncEditorState(app, editor, checkpointIdRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
setDisplayMode(newMode)
|
||||
}
|
||||
}
|
||||
|
||||
const runExec = (code: string, source: string, canvasId?: string) => {
|
||||
if (hasExecRunRef.current) {
|
||||
logIfDevMode(`Exec: skipping duplicate exec from ${source}`)
|
||||
return
|
||||
}
|
||||
hasExecRunRef.current = true
|
||||
|
||||
logIfDevMode(`Exec: running from ${source}`)
|
||||
markAiActivity()
|
||||
|
||||
void (async () => {
|
||||
logIfDevMode('Exec: waiting for editor...')
|
||||
const editor = await waitForEditor()
|
||||
|
||||
if (canvasId) {
|
||||
setCurrentCanvasId(canvasId)
|
||||
|
||||
if (editor.getCurrentPageShapeIds().size === 0) {
|
||||
logIfDevMode(`Exec: canvas empty, fetching state for canvasId=${canvasId}`)
|
||||
try {
|
||||
const response = await app.callServerTool({
|
||||
name: '_get_canvas_state',
|
||||
arguments: { canvasId },
|
||||
})
|
||||
const res = response as any
|
||||
let data: any = null
|
||||
// Try structuredContent first, fall back to parsing text content
|
||||
if (res?.structuredContent) {
|
||||
data = res.structuredContent
|
||||
} else if (Array.isArray(res?.content)) {
|
||||
const textItem = res.content.find(
|
||||
(c: any) => c.type === 'text' && typeof c.text === 'string'
|
||||
)
|
||||
if (textItem) {
|
||||
try {
|
||||
data = JSON.parse(textItem.text)
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data && Array.isArray(data.shapes) && data.shapes.length > 0) {
|
||||
const snapshot: CanvasSnapshot = {
|
||||
shapes: data.shapes,
|
||||
assets: Array.isArray(data.assets) ? data.assets : [],
|
||||
bindings: Array.isArray(data.bindings) ? data.bindings : [],
|
||||
}
|
||||
applySnapshot(editor, snapshot)
|
||||
committedSnapshotRef.current = snapshot
|
||||
if (typeof data.checkpointId === 'string') {
|
||||
checkpointIdRef.current = data.checkpointId
|
||||
}
|
||||
logIfDevMode(
|
||||
`Exec: restored ${data.shapes.length} shape(s) from server for canvasId=${canvasId}`
|
||||
)
|
||||
} else {
|
||||
logIfDevMode(`Exec: no shapes returned from server for canvasId=${canvasId}`)
|
||||
}
|
||||
} catch (err) {
|
||||
logIfDevMode(`Exec: failed to fetch canvas state: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logIfDevMode('Exec: editor ready, executing code')
|
||||
|
||||
const execResult = await executeCode(editor, code)
|
||||
logIfDevMode(
|
||||
`Exec ${execResult.success ? 'succeeded' : 'failed'}: ${JSON.stringify(execResult.result ?? execResult.error)}`
|
||||
)
|
||||
|
||||
// Resolve the waiting exec call. On same-session hosts this settles the
|
||||
// server's in-memory pending request directly; otherwise the server
|
||||
// forwards the result to the exec:<execKey> rendezvous DO, keyed by the
|
||||
// same (canvasId, code) pair both sides derive, so it still reaches the
|
||||
// waiting exec. `canvasId` here is the model-supplied input (undefined
|
||||
// for a new canvas) — the server keys on that same value, so folding it
|
||||
// in keeps same-code-different-canvas invocations from colliding.
|
||||
//
|
||||
// We also include the canvasId in the payload (when present) as a
|
||||
// belt-and-braces cross-check: the waiting exec rejects a delivered
|
||||
// result whose canvasId isn't the one it's editing. Omitted for new
|
||||
// canvases because the server-generated id isn't known here yet, and
|
||||
// sending a stale learned id would cause false mismatches.
|
||||
const execKey = await computeExecKey(code, canvasId)
|
||||
const resultPayload = execResult.success
|
||||
? { success: true, result: execResult.result }
|
||||
: { success: false, error: execResult.error }
|
||||
if (canvasId) {
|
||||
;(resultPayload as { canvasId?: string }).canvasId = canvasId
|
||||
}
|
||||
const callbackArgs = { channel: 'exec', execKey, result: resultPayload }
|
||||
try {
|
||||
await app.callServerTool({ name: '_exec_callback', arguments: callbackArgs })
|
||||
logIfDevMode('Exec: _exec_callback succeeded')
|
||||
} catch (err) {
|
||||
logIfDevMode(`Exec: _exec_callback failed: ${err}`)
|
||||
}
|
||||
|
||||
if (execResult.success) {
|
||||
const cpId = checkpointIdRef.current ?? crypto.randomUUID()
|
||||
checkpointIdRef.current = cpId
|
||||
|
||||
const resultStr =
|
||||
execResult.result !== undefined ? JSON.stringify(execResult.result, null, 2) : undefined
|
||||
committedSnapshotRef.current = syncEditorState(app, editor, cpId, {
|
||||
message: resultStr
|
||||
? `Code executed successfully on canvas. Return value:\n${resultStr}`
|
||||
: 'Code executed successfully on canvas.',
|
||||
})
|
||||
|
||||
const snapshot = committedSnapshotRef.current
|
||||
const allShapeIds = new Set(snapshot.shapes.map((s) => s.id))
|
||||
zoomToFitRequestShapes(editor, allShapeIds)
|
||||
} else {
|
||||
clearCanvasContext(app, {
|
||||
message:
|
||||
'Canvas context was cleared because code execution failed. Fix the error before using the canvas context again.',
|
||||
})
|
||||
teardownEditor()
|
||||
setExecError(execResult.error ?? 'Unknown error')
|
||||
void app.sendSizeChanged({ width: 400, height: ERROR_BANNER_HEIGHT })
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
app.ontoolinput = (params) => {
|
||||
logIfDevMode('Exec: ontoolinput called')
|
||||
const code = params.arguments?.code
|
||||
if (typeof code !== 'string' || !code.trim()) return
|
||||
const canvasId =
|
||||
typeof params.arguments?.canvasId === 'string' ? params.arguments.canvasId : undefined
|
||||
|
||||
if (execPartialDebounceRef.current !== null) {
|
||||
window.clearTimeout(execPartialDebounceRef.current)
|
||||
}
|
||||
execPartialDebounceRef.current = window.setTimeout(() => {
|
||||
execPartialDebounceRef.current = null
|
||||
runExec(code, 'ontoolinput (debounced)', canvasId)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
app.ontoolinputpartial = (params) => {
|
||||
const code = params.arguments?.code
|
||||
if (typeof code !== 'string' || !code.trim()) return
|
||||
const canvasId =
|
||||
typeof params.arguments?.canvasId === 'string' ? params.arguments.canvasId : undefined
|
||||
|
||||
if (execPartialDebounceRef.current !== null) {
|
||||
window.clearTimeout(execPartialDebounceRef.current)
|
||||
}
|
||||
execPartialDebounceRef.current = window.setTimeout(() => {
|
||||
execPartialDebounceRef.current = null
|
||||
runExec(code, 'ontoolinputpartial (debounced)', canvasId)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
app.onteardown = async () => {
|
||||
return {}
|
||||
}
|
||||
|
||||
app.ontoolresult = async (result) => {
|
||||
logIfDevMode('Exec: ontoolresult called')
|
||||
// If the code already ran for this invocation (via toolinput/partial), a
|
||||
// still-queued debounced run is a duplicate of it — cancel it before
|
||||
// resetting the run guard. If nothing ran yet (hosts that deliver input
|
||||
// and result together), keep the timer so the code still executes once.
|
||||
if (hasExecRunRef.current && execPartialDebounceRef.current !== null) {
|
||||
window.clearTimeout(execPartialDebounceRef.current)
|
||||
execPartialDebounceRef.current = null
|
||||
}
|
||||
hasExecRunRef.current = false
|
||||
markAiActivity()
|
||||
|
||||
// The tool result carries the server-assigned canvasId on every host,
|
||||
// including those that route widget calls over a separate MCP session. Once
|
||||
// learned, persist the canvas -> checkpoint mapping so future execs can
|
||||
// restore this canvas.
|
||||
const sc = (result as { structuredContent?: { canvasId?: unknown } }).structuredContent
|
||||
if (sc && typeof sc.canvasId === 'string' && sc.canvasId) {
|
||||
const hadCanvasId = getCurrentCanvasId() !== null
|
||||
setCurrentCanvasId(sc.canvasId)
|
||||
logIfDevMode(`Exec: canvasId from tool result: ${sc.canvasId}`)
|
||||
if (!hadCanvasId) {
|
||||
const editor = editorRef.current
|
||||
const cpId = checkpointIdRef.current
|
||||
if (editor && cpId) {
|
||||
void saveCheckpointToServer(app, cpId, editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkpoint = parseCheckpointFromToolResult(result)
|
||||
if (!checkpoint) return
|
||||
logIfDevMode(`Received tool result for checkpoint ${checkpoint.checkpointId}`)
|
||||
|
||||
const {
|
||||
checkpointId,
|
||||
sessionId,
|
||||
shapes: resultShapes,
|
||||
assets: resultAssets,
|
||||
bindings: resultBindings,
|
||||
} = checkpoint
|
||||
checkpointIdRef.current = checkpointId
|
||||
|
||||
if (sessionId) {
|
||||
setCurrentSessionId(sessionId)
|
||||
}
|
||||
|
||||
const localSnapshot = loadLocalSnapshot(checkpointId)
|
||||
const finalShapes = localSnapshot ? localSnapshot.shapes : resultShapes
|
||||
const finalAssets: TLAsset[] = localSnapshot ? localSnapshot.assets : resultAssets
|
||||
const finalBindings: TLBindingCreate[] = localSnapshot
|
||||
? localSnapshot.bindings
|
||||
: resultBindings
|
||||
|
||||
const previousCommittedIds = new Set(committedSnapshotRef.current.shapes.map((s) => s.id))
|
||||
|
||||
const snapshot: CanvasSnapshot = {
|
||||
shapes: finalShapes,
|
||||
assets: finalAssets,
|
||||
bindings: finalBindings,
|
||||
}
|
||||
committedSnapshotRef.current = snapshot
|
||||
|
||||
const editor = editorRef.current
|
||||
if (!editor) {
|
||||
pendingSnapshotRef.current = snapshot
|
||||
return
|
||||
}
|
||||
|
||||
applySnapshot(editor, snapshot)
|
||||
|
||||
if (!localSnapshot) {
|
||||
const newIds = new Set<TLShapeId>()
|
||||
for (const shape of finalShapes) {
|
||||
if (!previousCommittedIds.has(shape.id)) newIds.add(shape.id)
|
||||
}
|
||||
zoomToFitRequestShapes(editor, newIds)
|
||||
}
|
||||
|
||||
saveLocalSnapshot(checkpointId, finalShapes, finalAssets, finalBindings)
|
||||
void saveCheckpointToServer(app, checkpointId, editor)
|
||||
pushCanvasContext(app, editor)
|
||||
}
|
||||
|
||||
app.ontoolcancelled = (_params) => {
|
||||
hasExecRunRef.current = false
|
||||
if (execPartialDebounceRef.current !== null) {
|
||||
window.clearTimeout(execPartialDebounceRef.current)
|
||||
execPartialDebounceRef.current = null
|
||||
}
|
||||
markAiActivity()
|
||||
logIfDevMode('Tool invocation cancelled')
|
||||
}
|
||||
|
||||
return () => {
|
||||
teardownEditor()
|
||||
}
|
||||
}, [
|
||||
app,
|
||||
applyHostThemeToEditor,
|
||||
enableDevMode,
|
||||
logIfDevMode,
|
||||
markAiActivity,
|
||||
teardownEditor,
|
||||
waitForEditor,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobilePlatform || displayMode !== 'fullscreen') return
|
||||
|
||||
void app
|
||||
.requestDisplayMode({ mode: 'inline' })
|
||||
.then((result) => {
|
||||
const actualMode = result.mode === 'fullscreen' ? 'fullscreen' : 'inline'
|
||||
setDisplayMode(actualMode)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [app, displayMode, isMobilePlatform])
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMode === 'fullscreen' && containerHeight) {
|
||||
const h = `${containerHeight}px`
|
||||
document.documentElement.style.height = h
|
||||
document.body.style.height = h
|
||||
} else {
|
||||
document.documentElement.style.height = ''
|
||||
document.body.style.height = ''
|
||||
}
|
||||
}, [displayMode, containerHeight])
|
||||
|
||||
const handleMount = useCallback(
|
||||
(editor: Editor) => {
|
||||
editorRef.current = editor
|
||||
editor.user.updateUserPreferences({ colorScheme: canvasTheme })
|
||||
|
||||
if (editorReadyResolveRef.current) {
|
||||
editorReadyResolveRef.current(editor)
|
||||
editorReadyResolveRef.current = null
|
||||
editorReadyPromiseRef.current = null
|
||||
}
|
||||
|
||||
removeStoreListenerRef.current?.()
|
||||
removeStoreListenerRef.current = editor.store.listen(
|
||||
() => {
|
||||
markUserEdit()
|
||||
scheduleSave()
|
||||
},
|
||||
{ source: 'user', scope: 'document' }
|
||||
)
|
||||
|
||||
editor.sideEffects.registerAfterChangeHandler('instance', (prev, next) => {
|
||||
const pb = prev.screenBounds
|
||||
const nb = next.screenBounds
|
||||
const dw = nb.w - pb.w
|
||||
const dh = nb.h - pb.h
|
||||
if (dw === 0 && dh === 0) return
|
||||
|
||||
const cam = editor.getCamera()
|
||||
if (!Number.isFinite(cam.z) || cam.z <= 0) return
|
||||
const nextX = cam.x + dw / cam.z / 2
|
||||
const nextY = cam.y + dh / cam.z / 2
|
||||
if (!Number.isFinite(nextX) || !Number.isFinite(nextY)) return
|
||||
|
||||
editor.setCamera({
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
z: cam.z,
|
||||
})
|
||||
})
|
||||
|
||||
const pendingSnapshot = pendingSnapshotRef.current
|
||||
if (pendingSnapshot) {
|
||||
pendingSnapshotRef.current = null
|
||||
applySnapshot(editor, pendingSnapshot)
|
||||
}
|
||||
|
||||
pushCanvasContext(app, editor)
|
||||
},
|
||||
[app, canvasTheme, markUserEdit, scheduleSave]
|
||||
)
|
||||
|
||||
if (execError) {
|
||||
return (
|
||||
<div className={`mcp-app__error-banner${isDarkTheme ? ' mcp-app__error-banner--dark' : ''}`}>
|
||||
<img
|
||||
src={tldrawLogoUrl}
|
||||
alt="tldraw logo"
|
||||
className={`mcp-app__error-logo${isDarkTheme ? ' mcp-app__error-logo--dark' : ''}`}
|
||||
/>
|
||||
<span className="mcp-app__error-label">Error editing canvas:</span>
|
||||
<span
|
||||
title={execError}
|
||||
className={`mcp-app__error-message${isDarkTheme ? ' mcp-app__error-message--dark' : ''}`}
|
||||
>
|
||||
{execError}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isFullscreen = displayMode === 'fullscreen'
|
||||
|
||||
return (
|
||||
<McpAppContext.Provider value={mcpAppCtx}>
|
||||
<div
|
||||
className={`mcp-app__canvas-layout${isFullscreen ? ' mcp-app__canvas-layout--fullscreen' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`mcp-app__canvas-surface${isFullscreen ? ' mcp-app__canvas-surface--fullscreen' : ''}${devLogPanelHeight > 0 && !isFullscreen ? ' mcp-app__canvas-surface--with-dev-log' : ''}`}
|
||||
>
|
||||
<Tldraw
|
||||
licenseKey={LICENSE_KEY}
|
||||
onMount={handleMount}
|
||||
onUiEvent={handleUiEvent}
|
||||
components={tldrawComponents}
|
||||
overrides={uiOverrides}
|
||||
>
|
||||
<ImageDropGuard />
|
||||
</Tldraw>
|
||||
</div>
|
||||
{isDev && isDevLogVisible && (
|
||||
<DevLogPanel entries={devLogEntries} isFullscreen={isFullscreen} />
|
||||
)}
|
||||
</div>
|
||||
</McpAppContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function McpApp() {
|
||||
const { app, isConnected, error } = useApp({
|
||||
appInfo: {
|
||||
name: MCP_SERVER_NAME,
|
||||
version: MCP_SERVER_VERSION,
|
||||
title: MCP_SERVER_TITLE,
|
||||
description: MCP_SERVER_DESCRIPTION,
|
||||
websiteUrl: MCP_SERVER_WEBSITE_URL,
|
||||
},
|
||||
capabilities: {
|
||||
availableDisplayModes: ['fullscreen', 'inline'],
|
||||
},
|
||||
})
|
||||
|
||||
const status = isConnected ? 'ready' : 'connecting'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error ? (
|
||||
<div className="mcp-app__status mcp-app__status--error">Error: {error.message}</div>
|
||||
) : !isConnected || !app ? (
|
||||
<div className="mcp-app__status mcp-app__status--connecting">Status: {status}</div>
|
||||
) : (
|
||||
<TldrawCanvas app={app} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<McpApp />)
|
||||
333
apps/mcp-app/src/widget/persistence.ts
Normal file
333
apps/mcp-app/src/widget/persistence.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { App } from '@modelcontextprotocol/ext-apps/react'
|
||||
import { Editor, structuredClone } from 'tldraw'
|
||||
import type { TLAsset, TLBindingCreate, TLShape } from 'tldraw'
|
||||
import { isPlainObject } from '../shared/utils'
|
||||
import { convertTldrawShapeToFocusedShape } from './focused/to-focused'
|
||||
|
||||
export interface CanvasSnapshot {
|
||||
shapes: TLShape[]
|
||||
assets: TLAsset[]
|
||||
bindings?: TLBindingCreate[]
|
||||
}
|
||||
|
||||
// --- Canvas-scoped localStorage persistence ---
|
||||
|
||||
let currentSessionId: string | null = null
|
||||
let currentCanvasId: string | null = null
|
||||
|
||||
export function setCurrentSessionId(id: string): void {
|
||||
currentSessionId = id
|
||||
}
|
||||
|
||||
export function setCurrentCanvasId(id: string): void {
|
||||
currentCanvasId = id
|
||||
}
|
||||
|
||||
export function getCurrentCanvasId(): string | null {
|
||||
return currentCanvasId
|
||||
}
|
||||
|
||||
function localStorageKey(checkpointId: string): string {
|
||||
if (currentCanvasId) return `tldraw:canvas:${currentCanvasId}:${checkpointId}`
|
||||
if (currentSessionId) return `tldraw:${currentSessionId}:${checkpointId}`
|
||||
return `tldraw:${checkpointId}`
|
||||
}
|
||||
|
||||
function parseSnapshotData(
|
||||
raw: string
|
||||
): { shapes: TLShape[]; assets: TLAsset[]; bindings: TLBindingCreate[] } | null {
|
||||
const parsed = JSON.parse(raw)
|
||||
// Backwards compat: old format was a plain array of shapes
|
||||
if (Array.isArray(parsed)) {
|
||||
const shapes = parsed.filter(
|
||||
(s: unknown): s is TLShape =>
|
||||
isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
|
||||
)
|
||||
if (parsed.length > 0 && shapes.length === 0) return null
|
||||
return { shapes, assets: [], bindings: [] }
|
||||
}
|
||||
if (!isPlainObject(parsed) || !Array.isArray(parsed.shapes)) return null
|
||||
const shapes = parsed.shapes.filter(
|
||||
(s: unknown): s is TLShape =>
|
||||
isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
|
||||
)
|
||||
if (parsed.shapes.length > 0 && shapes.length === 0) return null
|
||||
const assets = (Array.isArray(parsed.assets) ? parsed.assets : []).filter(
|
||||
(a: unknown): a is TLAsset => isPlainObject(a) && typeof a.id === 'string'
|
||||
)
|
||||
const bindings = (Array.isArray(parsed.bindings) ? parsed.bindings : []) as TLBindingCreate[]
|
||||
return { shapes, assets, bindings }
|
||||
}
|
||||
|
||||
export function loadLocalSnapshot(
|
||||
checkpointId: string
|
||||
): { shapes: TLShape[]; assets: TLAsset[]; bindings: TLBindingCreate[] } | null {
|
||||
try {
|
||||
// eslint-disable-next-line tldraw/no-direct-storage
|
||||
const raw = localStorage.getItem(localStorageKey(checkpointId))
|
||||
if (!raw) return null
|
||||
return parseSnapshotData(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLocalSnapshot(
|
||||
checkpointId: string,
|
||||
shapes: TLShape[],
|
||||
assets: TLAsset[] = [],
|
||||
bindings: TLBindingCreate[] = []
|
||||
): void {
|
||||
const scopeId = currentCanvasId ?? currentSessionId
|
||||
if (!scopeId) return
|
||||
try {
|
||||
// eslint-disable-next-line tldraw/no-direct-storage
|
||||
localStorage.setItem(
|
||||
localStorageKey(checkpointId),
|
||||
JSON.stringify({ shapes, assets, bindings })
|
||||
)
|
||||
const latestKey = currentCanvasId
|
||||
? `tldraw:canvas:${currentCanvasId}:latest`
|
||||
: `tldraw:${currentSessionId}:latest`
|
||||
// eslint-disable-next-line tldraw/no-direct-storage
|
||||
localStorage.setItem(latestKey, checkpointId)
|
||||
} catch {
|
||||
// localStorage may be full or unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export function getLatestCheckpointSnapshot(): {
|
||||
shapes: TLShape[]
|
||||
assets: TLAsset[]
|
||||
bindings: TLBindingCreate[]
|
||||
} | null {
|
||||
const scopeId = currentCanvasId ?? currentSessionId
|
||||
if (!scopeId) return null
|
||||
try {
|
||||
const latestKey = currentCanvasId
|
||||
? `tldraw:canvas:${currentCanvasId}:latest`
|
||||
: `tldraw:${currentSessionId}:latest`
|
||||
// eslint-disable-next-line tldraw/no-direct-storage
|
||||
const latestId = localStorage.getItem(latestKey)
|
||||
if (!latestId) return null
|
||||
return loadLocalSnapshot(latestId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Embedded bootstrap ---
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TLDRAW_BOOTSTRAP__?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmbeddedBootstrap(): {
|
||||
sessionId: string
|
||||
canvasId?: string
|
||||
checkpointId?: string
|
||||
isDev: boolean
|
||||
workerOrigin?: string
|
||||
mcpSessionId?: string
|
||||
snapshot?: CanvasSnapshot
|
||||
} | null {
|
||||
const data = window.__TLDRAW_BOOTSTRAP__
|
||||
if (!isPlainObject(data)) return null
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : null
|
||||
if (!sessionId) return null
|
||||
|
||||
const canvasId = typeof data.canvasId === 'string' ? data.canvasId : undefined
|
||||
const checkpointId = typeof data.checkpointId === 'string' ? data.checkpointId : undefined
|
||||
const isDev = data.isDev === true
|
||||
const workerOrigin = typeof data.workerOrigin === 'string' ? data.workerOrigin : undefined
|
||||
const mcpSessionId = typeof data.mcpSessionId === 'string' ? data.mcpSessionId : undefined
|
||||
|
||||
let snapshot: CanvasSnapshot | undefined
|
||||
if (Array.isArray(data.shapes)) {
|
||||
const shapes = data.shapes.filter(
|
||||
(s: unknown): s is TLShape =>
|
||||
isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
|
||||
)
|
||||
const assets = (Array.isArray(data.assets) ? data.assets : []).filter(
|
||||
(a: unknown): a is TLAsset => isPlainObject(a) && typeof a.id === 'string'
|
||||
)
|
||||
const bindings = (Array.isArray(data.bindings) ? data.bindings : []) as TLBindingCreate[]
|
||||
if (data.shapes.length === 0 || shapes.length > 0) {
|
||||
snapshot = { shapes, assets, bindings }
|
||||
}
|
||||
}
|
||||
|
||||
return { sessionId, canvasId, checkpointId, isDev, workerOrigin, mcpSessionId, snapshot }
|
||||
}
|
||||
|
||||
// --- Tool result parsing ---
|
||||
|
||||
function toSnapshotShapesFromRecords(value: unknown): TLShape[] | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
return value.filter(
|
||||
(shape): shape is TLShape =>
|
||||
isPlainObject(shape) && typeof shape.id === 'string' && typeof shape.type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function toAssetRecords(value: unknown): TLAsset[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((a): a is TLAsset => isPlainObject(a) && typeof a.id === 'string')
|
||||
}
|
||||
|
||||
interface CheckpointResult {
|
||||
checkpointId: string
|
||||
sessionId: string | null
|
||||
shapes: TLShape[]
|
||||
assets: TLAsset[]
|
||||
bindings: TLBindingCreate[]
|
||||
action: string | null
|
||||
/** True if the server found base shapes to merge with (for create action). */
|
||||
hadBaseShapes: boolean
|
||||
/** True if the server started from a blank canvas (for create action). */
|
||||
newBlankCanvas: boolean
|
||||
}
|
||||
|
||||
function toBindingRecords(value: unknown): TLBindingCreate[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(
|
||||
(b): b is TLBindingCreate =>
|
||||
isPlainObject(b) && typeof b.type === 'string' && typeof b.fromId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function parseCheckpointFromToolResult(result: unknown): CheckpointResult | null {
|
||||
if (!isPlainObject(result)) return null
|
||||
const sc = result.structuredContent
|
||||
if (!isPlainObject(sc)) return null
|
||||
const checkpointId = typeof sc.checkpointId === 'string' ? sc.checkpointId : null
|
||||
if (!checkpointId) return null
|
||||
const shapes = toSnapshotShapesFromRecords(sc.tldrawRecords)
|
||||
if (!shapes) return null
|
||||
|
||||
const assets = toAssetRecords(sc.assets ?? sc.assetRecords)
|
||||
const bindings = toBindingRecords(sc.bindings)
|
||||
return {
|
||||
checkpointId,
|
||||
sessionId: typeof sc.sessionId === 'string' ? sc.sessionId : null,
|
||||
shapes,
|
||||
assets,
|
||||
bindings,
|
||||
action: typeof sc.action === 'string' ? sc.action : null,
|
||||
hadBaseShapes: sc.hadBaseShapes === true,
|
||||
newBlankCanvas: sc.newBlankCanvas === true,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Snapshot capture + sync ---
|
||||
|
||||
/**
|
||||
* Capture the current editor state as a cloned snapshot.
|
||||
*/
|
||||
export function captureEditorSnapshot(editor: Editor): CanvasSnapshot {
|
||||
return {
|
||||
shapes: [...editor.getCurrentPageShapes()].map((s) => structuredClone(s)),
|
||||
assets: [...editor.getAssets()].map((a) => structuredClone(a)),
|
||||
bindings: getEditorBindings(editor),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push model context and persist the editor state to localStorage + server.
|
||||
* Returns the captured snapshot so the caller can store it.
|
||||
*/
|
||||
export function syncEditorState(
|
||||
app: App,
|
||||
editor: Editor,
|
||||
checkpointId: string | null,
|
||||
opts?: { message?: string }
|
||||
): CanvasSnapshot {
|
||||
const snapshot = captureEditorSnapshot(editor)
|
||||
pushCanvasContext(app, editor, opts)
|
||||
if (checkpointId) {
|
||||
saveLocalSnapshot(checkpointId, snapshot.shapes, snapshot.assets, snapshot.bindings ?? [])
|
||||
void saveCheckpointToServer(app, checkpointId, editor)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// --- Canvas context push ---
|
||||
|
||||
export function pushCanvasContext(app: App, editor: Editor, opts?: { message?: string }) {
|
||||
const shapes = [...editor.getCurrentPageShapes()].map((shape) =>
|
||||
convertTldrawShapeToFocusedShape(editor, shape)
|
||||
)
|
||||
|
||||
const canvasStatus = shapes.length > 0 ? `Current canvas state is attached.` : 'Canvas is empty.'
|
||||
|
||||
const text = opts?.message ? `${opts.message}\n\n${canvasStatus}` : canvasStatus
|
||||
|
||||
void app.updateModelContext({
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: {
|
||||
shapes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function clearCanvasContext(app: App, opts?: { message?: string }) {
|
||||
const text = opts?.message ?? 'Canvas context was cleared.'
|
||||
void app.updateModelContext({
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: {
|
||||
shapes: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getEditorBindings(editor: Editor): TLBindingCreate[] {
|
||||
const bindings: TLBindingCreate[] = []
|
||||
for (const record of editor.getCurrentPageShapes()) {
|
||||
if (record.type !== 'arrow') continue
|
||||
const arrowBindings = editor.getBindingsFromShape(record.id, 'arrow')
|
||||
for (const binding of arrowBindings) {
|
||||
bindings.push({
|
||||
type: binding.type,
|
||||
fromId: binding.fromId,
|
||||
toId: binding.toId,
|
||||
props: structuredClone(binding.props),
|
||||
meta: structuredClone(binding.meta),
|
||||
} as TLBindingCreate)
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
export async function saveCheckpointToServer(
|
||||
app: App,
|
||||
checkpointId: string,
|
||||
editor: Editor
|
||||
): Promise<boolean> {
|
||||
const shapes = [...editor.getCurrentPageShapes()].map((s) => structuredClone(s))
|
||||
const assets = [...editor.getAssets()].map((a) => structuredClone(a))
|
||||
const bindings = getEditorBindings(editor)
|
||||
const args: Record<string, string> = {
|
||||
checkpointId,
|
||||
shapesJson: JSON.stringify(shapes),
|
||||
}
|
||||
if (assets.length > 0) {
|
||||
args.assetsJson = JSON.stringify(assets)
|
||||
}
|
||||
if (bindings.length > 0) {
|
||||
args.bindingsJson = JSON.stringify(bindings)
|
||||
}
|
||||
if (currentCanvasId) {
|
||||
args.canvasId = currentCanvasId
|
||||
}
|
||||
try {
|
||||
const result = await app.callServerTool({
|
||||
name: 'save_checkpoint',
|
||||
arguments: args,
|
||||
})
|
||||
return result.isError !== true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
157
apps/mcp-app/src/widget/snapshot.ts
Normal file
157
apps/mcp-app/src/widget/snapshot.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Box, Editor, structuredClone } from 'tldraw'
|
||||
import type { TLBindingCreate, TLShape, TLShapeId } from 'tldraw'
|
||||
import type { CanvasSnapshot } from './persistence'
|
||||
|
||||
/**
|
||||
* Re-run each ShapeUtil's onBeforeCreate to recalculate auto-sizing dimensions
|
||||
* (e.g. growY for geo/note shapes). This compensates for shapes arriving from
|
||||
* the server with static dimensions (growY: 0) that get applied via updateShapes,
|
||||
* which doesn't trigger the sizing recalculation in onBeforeUpdate unless
|
||||
* text/font/size actually changed.
|
||||
*/
|
||||
function forceAutoSize(editor: Editor) {
|
||||
const updates: TLShape[] = []
|
||||
for (const shape of editor.getCurrentPageShapes()) {
|
||||
const util = editor.getShapeUtil(shape)
|
||||
const adjusted = util.onBeforeCreate?.(shape as any)
|
||||
if (adjusted) {
|
||||
updates.push(adjusted as TLShape)
|
||||
}
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
editor.updateShapes(updates)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bindings on the editor from a list of TLBindingCreate objects.
|
||||
* Only creates bindings where the target shape exists on the page.
|
||||
* Removes existing arrow bindings for affected arrows to prevent duplicates.
|
||||
*/
|
||||
function applyBindings(editor: Editor, bindings: TLBindingCreate[]) {
|
||||
if (bindings.length === 0) return
|
||||
|
||||
// Collect arrow shape IDs that have bindings to dedup
|
||||
const arrowIds = new Set(bindings.map((b) => b.fromId))
|
||||
for (const arrowId of arrowIds) {
|
||||
const existing = editor.getBindingsFromShape(arrowId, 'arrow')
|
||||
if (existing.length > 0) {
|
||||
editor.deleteBindings(existing)
|
||||
}
|
||||
}
|
||||
|
||||
for (const binding of bindings) {
|
||||
if (editor.getShape(binding.toId)) {
|
||||
editor.createBinding(binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CAMERA_ANIM_MS = 300
|
||||
let cameraAnimEndTime = 0
|
||||
|
||||
/**
|
||||
* If any of the given shape IDs extend outside the current viewport,
|
||||
* pan (and only zoom out if necessary) to keep them visible.
|
||||
* Never zooms in beyond the current zoom level.
|
||||
* Skips the call if a previous animation is still playing.
|
||||
*/
|
||||
export function zoomToFitRequestShapes(editor: Editor, shapeIds: Set<TLShapeId>) {
|
||||
if (shapeIds.size === 0) return
|
||||
|
||||
// Don't interrupt an in-progress animation — wait for it to finish
|
||||
if (Date.now() < cameraAnimEndTime) return
|
||||
|
||||
const shapeBounds: Box[] = []
|
||||
for (const id of shapeIds) {
|
||||
const bounds = editor.getShapePageBounds(id)
|
||||
if (bounds) shapeBounds.push(bounds)
|
||||
}
|
||||
if (shapeBounds.length === 0) return
|
||||
|
||||
const commonBounds = Box.Common(shapeBounds)
|
||||
const viewportBounds = editor.getViewportPageBounds()
|
||||
|
||||
// All request shapes already visible — nothing to do
|
||||
const contained =
|
||||
commonBounds.x >= viewportBounds.x &&
|
||||
commonBounds.y >= viewportBounds.y &&
|
||||
commonBounds.x + commonBounds.w <= viewportBounds.x + viewportBounds.w &&
|
||||
commonBounds.y + commonBounds.h <= viewportBounds.y + viewportBounds.h
|
||||
if (contained) return
|
||||
|
||||
const currentZoom = editor.getZoomLevel()
|
||||
const screenBounds = editor.getViewportScreenBounds()
|
||||
const inset = 100
|
||||
|
||||
// The zoom level needed to fit the shapes with padding
|
||||
const fitZoomX =
|
||||
commonBounds.w > 0 ? (screenBounds.w - inset) / commonBounds.w : Number.POSITIVE_INFINITY
|
||||
const fitZoomY =
|
||||
commonBounds.h > 0 ? (screenBounds.h - inset) / commonBounds.h : Number.POSITIVE_INFINITY
|
||||
const fitZoom = Math.min(fitZoomX, fitZoomY)
|
||||
|
||||
// Never zoom in past the current level — only zoom out if shapes don't fit
|
||||
const zoom = Math.min(currentZoom, fitZoom)
|
||||
if (!Number.isFinite(zoom) || zoom <= 0) return
|
||||
|
||||
// Center the shapes in the viewport at the chosen zoom
|
||||
const cx = commonBounds.x + commonBounds.w / 2
|
||||
const cy = commonBounds.y + commonBounds.h / 2
|
||||
const cameraX = -cx + screenBounds.w / zoom / 2
|
||||
const cameraY = -cy + screenBounds.h / zoom / 2
|
||||
if (!Number.isFinite(cameraX) || !Number.isFinite(cameraY)) return
|
||||
|
||||
cameraAnimEndTime = Date.now() + CAMERA_ANIM_MS
|
||||
|
||||
editor.setCamera(
|
||||
{
|
||||
x: cameraX,
|
||||
y: cameraY,
|
||||
z: zoom,
|
||||
},
|
||||
{ animation: { duration: CAMERA_ANIM_MS } }
|
||||
)
|
||||
}
|
||||
|
||||
export function applySnapshot(editor: Editor, snapshot: CanvasSnapshot) {
|
||||
const nextShapes = snapshot.shapes.map((shape) => structuredClone(shape))
|
||||
const nextAssets = (snapshot.assets ?? []).map((asset) => structuredClone(asset))
|
||||
const nextBindings = (snapshot.bindings ?? []) as TLBindingCreate[]
|
||||
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.run(
|
||||
() => {
|
||||
// Restore asset records first so image shapes can resolve them
|
||||
if (nextAssets.length > 0) {
|
||||
const existingAssetIds = new Set(editor.getAssets().map((a) => a.id))
|
||||
for (const asset of nextAssets) {
|
||||
if (!existingAssetIds.has(asset.id)) {
|
||||
editor.createAssets([asset])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingIds = [...editor.getCurrentPageShapeIds()]
|
||||
if (existingIds.length > 0) {
|
||||
editor.deleteShapes(existingIds)
|
||||
}
|
||||
if (nextShapes.length <= 0) return
|
||||
|
||||
// Preserve parent relationships while allowing the editor to assign fresh indices.
|
||||
const createInputs = nextShapes.map((shape) => {
|
||||
const { index: _index, ...partial } = shape
|
||||
return partial
|
||||
})
|
||||
editor.createShapes(createInputs)
|
||||
|
||||
// Re-run auto-sizing so growY / fontSizeAdjustment are correct
|
||||
forceAutoSize(editor)
|
||||
|
||||
// Create bindings after all shapes are on the page
|
||||
applyBindings(editor, nextBindings)
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
})
|
||||
}
|
||||
567
apps/mcp-app/src/worker.ts
Normal file
567
apps/mcp-app/src/worker.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
/**
|
||||
* Cloudflare Workers entry point for the tldraw MCP server.
|
||||
*
|
||||
* Uses a Durable Object (McpAgent) with SQLite for persistent checkpoint storage,
|
||||
* R2 for image uploads, and the shared registerTools() for tool registration.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { McpAgent } from 'agents/mcp'
|
||||
import { CanvasStore } from './canvas-store'
|
||||
import { Logger } from './logger'
|
||||
import { registerTools } from './register-tools'
|
||||
import { loadEditorApiSpecFromAssets, loadMethodMapFromAssets } from './shared/generated-data'
|
||||
import { PendingRequests } from './shared/pending-requests'
|
||||
import {
|
||||
IDLE_TTL_MS,
|
||||
MAX_CHECKPOINTS,
|
||||
MCP_SERVER_DESCRIPTION,
|
||||
MCP_SERVER_INSTRUCTIONS,
|
||||
MCP_SERVER_NAME,
|
||||
MCP_SERVER_TITLE,
|
||||
MCP_SERVER_VERSION,
|
||||
MCP_SERVER_WEBSITE_URL,
|
||||
nextExpiryTime,
|
||||
} from './shared/types'
|
||||
import type { MCP_APP_HOST_NAMES, PendingBootstrap, ServerDeps } from './shared/types'
|
||||
import { resolveMcpAppHostNameFromServerInfo } from './shared/utils'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export { CanvasStore }
|
||||
|
||||
interface Env {
|
||||
MCP_OBJECT: DurableObjectNamespace<TldrawMCP>
|
||||
CANVAS_STORE: DurableObjectNamespace<CanvasStore>
|
||||
ASSETS: Fetcher
|
||||
LOADER: WorkerLoader
|
||||
RATE_LIMITER: RateLimit
|
||||
MCP_AUTH_TOKEN: string
|
||||
MCP_IS_DEV: string
|
||||
WORKER_ORIGIN: string
|
||||
MCP_ANALYTICS?: AnalyticsEngineDataset
|
||||
IDLE_TTL_MS_OVERRIDE?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Grace period before a condemned object's alarm runs destroy(). Also keeps that
|
||||
* alarm off the running alarm's own timestamp: see TldrawMCP.alarm.
|
||||
*/
|
||||
const DESTROY_ALARM_DELAY_MS = 1000
|
||||
|
||||
// Gated on MCP_IS_DEV so no deployed env can shorten the TTL. idle-expiry.test.ts
|
||||
// needs it: the real TTL is seven days, and the test has to see an alarm fire.
|
||||
function idleTtlMs(env: Env): number {
|
||||
const override = env.MCP_IS_DEV === 'true' ? Number(env.IDLE_TTL_MS_OVERRIDE) : NaN
|
||||
return Number.isFinite(override) && override > 0 ? override : IDLE_TTL_MS
|
||||
}
|
||||
|
||||
// --- Widget HTML loader ---
|
||||
|
||||
async function loadWidgetHtml(assets: Fetcher): Promise<string> {
|
||||
const response = await assets.fetch(new Request('https://assets.local/mcp-app.html'))
|
||||
if (!response.ok) throw new Error(`Failed to load widget HTML: ${response.status}`)
|
||||
return response.text()
|
||||
}
|
||||
|
||||
// --- CORS helpers ---
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id',
|
||||
}
|
||||
|
||||
function corsResponse(response: Response): Response {
|
||||
const headers = new Headers(response.headers)
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
return new Response(response.body, { status: response.status, headers })
|
||||
}
|
||||
|
||||
// --- McpAgent Durable Object ---
|
||||
|
||||
export class TldrawMCP extends McpAgent<Env> {
|
||||
override server = new McpServer(
|
||||
{
|
||||
name: MCP_SERVER_NAME,
|
||||
title: MCP_SERVER_TITLE,
|
||||
version: MCP_SERVER_VERSION,
|
||||
description: MCP_SERVER_DESCRIPTION,
|
||||
websiteUrl: MCP_SERVER_WEBSITE_URL,
|
||||
},
|
||||
{
|
||||
instructions: MCP_SERVER_INSTRUCTIONS,
|
||||
}
|
||||
)
|
||||
// The Agents SDK defaults `observability` to an implementation whose `emit()`
|
||||
// is a bare `console.log(event)`, so every WebSocket connection logs a full
|
||||
// JSON object. The streamable-HTTP transport opens a connection per request,
|
||||
// making this the single noisiest thing the worker prints. Every call site
|
||||
// uses `this.observability?.emit(...)`, so clearing it disables them all.
|
||||
// Also load-bearing for teardown: destroy() calls _emit(), whose `name: this.name`
|
||||
// argument is only skipped because the optional chain short-circuits. On the
|
||||
// destroy-marker alarm branch, which returns before partyserver's init runs,
|
||||
// #_name is never hydrated and reading it throws.
|
||||
override observability = undefined
|
||||
// The SDK's default DurableObjectEventStore persists every outgoing message to DO storage
|
||||
// (for Last-Event-ID replay) before writing it to the wire. SQLite-backed DO storage caps a
|
||||
// value at 2MB, and our widget resource is ~2.4MB — the put throws, the rejection is
|
||||
// swallowed, and the response silently never sends (the mcp-app hang of Aug 2026). The
|
||||
// transport optional-chains the store, so disabling it sends messages straight to the
|
||||
// stream. Costs stream resumability, which the MCP spec makes optional and which 0.5.x
|
||||
// never had either. See the large-payload smoke test before re-enabling.
|
||||
override getEventStore() {
|
||||
return undefined
|
||||
}
|
||||
isDev = this.env.MCP_IS_DEV === 'true'
|
||||
logsEnabled = this.isDev
|
||||
activeCheckpointId: string | null = null
|
||||
sessionId: string = ''
|
||||
logger = new Logger('TldrawMCP', this.logsEnabled)
|
||||
clientHostName: MCP_APP_HOST_NAMES | undefined = undefined
|
||||
pendingRequests = new PendingRequests()
|
||||
pendingBootstrap: PendingBootstrap | null = null
|
||||
|
||||
/** The MCP session ID used for DO routing (extracted from DO name). */
|
||||
getMcpSessionId(): string {
|
||||
return (this as any).name?.replace(/^streamable-http:/, '') ?? ''
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.server.server.oninitialized = () => {
|
||||
const clientInfo = this.server.server.getClientVersion()
|
||||
const resolved = resolveMcpAppHostNameFromServerInfo(clientInfo?.name ?? '')
|
||||
if (resolved) {
|
||||
this.clientHostName = resolved
|
||||
void this
|
||||
.sql`INSERT OR REPLACE INTO meta (key, value) VALUES ('clientHostName', ${resolved})`
|
||||
}
|
||||
this.logger.info(`Client connected: ${this.clientHostName ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
// --- DO SQLite setup ---
|
||||
void this
|
||||
.sql`CREATE TABLE IF NOT EXISTS checkpoints (id TEXT PRIMARY KEY, data TEXT, created_at INTEGER)`
|
||||
void this.sql`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)`
|
||||
void this
|
||||
.sql`CREATE TABLE IF NOT EXISTS canvas_checkpoints (canvas_id TEXT PRIMARY KEY, checkpoint_id TEXT)`
|
||||
|
||||
// Restore active checkpoint on reconnect
|
||||
const rows = [...this.sql`SELECT value FROM meta WHERE key = 'activeCheckpointId'`]
|
||||
if (rows.length > 0) {
|
||||
this.activeCheckpointId = rows[0].value as string
|
||||
this.logger.info('Restored active checkpoint', { checkpointId: this.activeCheckpointId })
|
||||
}
|
||||
|
||||
// Restore client host name on reconnect
|
||||
const hostNameRows = [...this.sql`SELECT value FROM meta WHERE key = 'clientHostName'`]
|
||||
if (hostNameRows.length > 0) {
|
||||
this.clientHostName = hostNameRows[0].value as MCP_APP_HOST_NAMES
|
||||
this.logger.info(`Restored client host name: ${this.clientHostName}`)
|
||||
}
|
||||
|
||||
// Restore or generate session ID
|
||||
const sessionRows = [...this.sql`SELECT value FROM meta WHERE key = 'sessionId'`]
|
||||
if (sessionRows.length > 0) {
|
||||
this.sessionId = sessionRows[0].value as string
|
||||
} else {
|
||||
this.sessionId = crypto.randomUUID()
|
||||
void this
|
||||
.sql`INSERT OR REPLACE INTO meta (key, value) VALUES ('sessionId', ${this.sessionId})`
|
||||
|
||||
// Track new session
|
||||
this.env.MCP_ANALYTICS?.writeDataPoint({
|
||||
blobs: ['session_start', this.sessionId],
|
||||
doubles: [Date.now()],
|
||||
})
|
||||
}
|
||||
|
||||
// --- Widget HTML (loaded once from Assets binding) ---
|
||||
const widgetHtml = await loadWidgetHtml(this.env.ASSETS)
|
||||
let editorApiSpecPromise: ReturnType<typeof loadEditorApiSpecFromAssets> | null = null
|
||||
let methodMapPromise: ReturnType<typeof loadMethodMapFromAssets> | null = null
|
||||
|
||||
// --- Exec rendezvous stubs (host-session-independent result handoff) ---
|
||||
const canvasStoreNs = this.env.CANVAS_STORE
|
||||
const canvasStoreStub = (name: string) => canvasStoreNs.get(canvasStoreNs.idFromName(name))
|
||||
|
||||
// --- Build ServerDeps from SQLite ---
|
||||
const deps: ServerDeps = {
|
||||
saveCheckpoint: (id, shapes, assets = [], bindings = []) =>
|
||||
this.saveCheckpoint(id, shapes, assets, bindings),
|
||||
loadCheckpoint: (id) => this.loadCheckpoint(id),
|
||||
getActiveCheckpointId: () => this.activeCheckpointId,
|
||||
setActiveCheckpointId: (id) => {
|
||||
this.activeCheckpointId = id
|
||||
void this.sql`INSERT OR REPLACE INTO meta (key, value) VALUES ('activeCheckpointId', ${id})`
|
||||
},
|
||||
getCanvasCheckpointId: (canvasId) => {
|
||||
const rows = [
|
||||
...this.sql`SELECT checkpoint_id FROM canvas_checkpoints WHERE canvas_id = ${canvasId}`,
|
||||
]
|
||||
return rows.length > 0 ? (rows[0].checkpoint_id as string) : null
|
||||
},
|
||||
setCanvasCheckpointId: (canvasId, checkpointId) => {
|
||||
void this
|
||||
.sql`INSERT OR REPLACE INTO canvas_checkpoints (canvas_id, checkpoint_id) VALUES (${canvasId}, ${checkpointId})`
|
||||
},
|
||||
setPendingBootstrap: (bootstrap) => {
|
||||
this.pendingBootstrap = bootstrap
|
||||
},
|
||||
consumePendingBootstrap: () => {
|
||||
const b = this.pendingBootstrap
|
||||
this.pendingBootstrap = null
|
||||
return b
|
||||
},
|
||||
getSessionId: () => this.sessionId,
|
||||
getMcpSessionId: () => this.getMcpSessionId(),
|
||||
loadWidgetHtml: async () => widgetHtml,
|
||||
loadEditorApiSpec: async () => {
|
||||
editorApiSpecPromise ??= loadEditorApiSpecFromAssets(this.env.ASSETS)
|
||||
return editorApiSpecPromise
|
||||
},
|
||||
loadMethodMap: async () => {
|
||||
methodMapPromise ??= loadMethodMapFromAssets(this.env.ASSETS)
|
||||
return methodMapPromise
|
||||
},
|
||||
putExecResult: async (execKey, payload) => {
|
||||
const { delivered } = await canvasStoreStub(`exec:${execKey}`).putExecResult(
|
||||
execKey,
|
||||
JSON.stringify(payload)
|
||||
)
|
||||
return delivered
|
||||
},
|
||||
waitExecResult: async (execKey, timeoutMs, notBefore) => {
|
||||
const payload = await canvasStoreStub(`exec:${execKey}`).waitExecResult(
|
||||
execKey,
|
||||
timeoutMs,
|
||||
notBefore
|
||||
)
|
||||
return payload ? JSON.parse(payload) : null
|
||||
},
|
||||
}
|
||||
|
||||
const workerOrigin = this.env.WORKER_ORIGIN
|
||||
|
||||
registerTools(this.server, deps, {
|
||||
log: this.logger.toLogFn(),
|
||||
extraResourceDomains: workerOrigin ? [workerOrigin] : [],
|
||||
extraConnectDomains: workerOrigin ? [workerOrigin] : [],
|
||||
searchWorkerLoader: this.env.LOADER,
|
||||
workerOrigin,
|
||||
isDev: this.isDev,
|
||||
analytics: this.env.MCP_ANALYTICS,
|
||||
getClientHostName: () => this.clientHostName,
|
||||
pendingRequests: this.pendingRequests,
|
||||
})
|
||||
|
||||
// Every DO that initializes gets its own expiry, including sessions that
|
||||
// never save and junk DOs minted by stale-session probes. `idempotent`
|
||||
// dedups on callback+payload across cold starts; without it the SDK warns
|
||||
// on every wake and stacks rows. Failure here must not fail init — the next
|
||||
// wake re-arms.
|
||||
try {
|
||||
await this.schedule(new Date(Date.now() + idleTtlMs(this.env)), 'expireIfIdle', null, {
|
||||
idempotent: true,
|
||||
})
|
||||
} catch (err) {
|
||||
// console.error, not this.logger: the logger is dev-only and a silently
|
||||
// unarmed expiry is the failure this branch exists to prevent.
|
||||
console.error('[TldrawMCP] failed to arm idle expiry', this.ctx.id.toString(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Checkpoint helpers ---
|
||||
|
||||
saveCheckpoint(id: string, shapes: unknown[], assets: unknown[] = [], bindings: unknown[] = []) {
|
||||
const data = JSON.stringify({ shapes, assets, bindings })
|
||||
void this
|
||||
.sql`INSERT OR REPLACE INTO checkpoints (id, data, created_at) VALUES (${id}, ${data}, ${Date.now()})`
|
||||
void this
|
||||
.sql`INSERT OR REPLACE INTO meta (key, value) VALUES ('lastActivity', ${String(Date.now())})`
|
||||
this.activeCheckpointId = id
|
||||
void this.sql`INSERT OR REPLACE INTO meta (key, value) VALUES ('activeCheckpointId', ${id})`
|
||||
|
||||
// Keep the LRU window, but never evict a checkpoint a canvas still points at: a
|
||||
// multi-canvas session would otherwise silently reopen an older canvas empty.
|
||||
// `IS NOT NULL`: one NULL in a NOT IN subquery makes the whole predicate NULL and
|
||||
// the DELETE silently matches nothing, forever.
|
||||
void this
|
||||
.sql`DELETE FROM checkpoints WHERE id NOT IN (SELECT id FROM checkpoints ORDER BY created_at DESC LIMIT ${MAX_CHECKPOINTS}) AND id NOT IN (SELECT checkpoint_id FROM canvas_checkpoints WHERE checkpoint_id IS NOT NULL)`
|
||||
|
||||
this.logger.debug('Checkpoint saved', { checkpointId: id, shapes: shapes.length })
|
||||
}
|
||||
|
||||
loadCheckpoint(id: string): { shapes: unknown[]; assets: unknown[]; bindings: unknown[] } | null {
|
||||
const rows = [...this.sql`SELECT data FROM checkpoints WHERE id = ${id}`]
|
||||
if (rows.length === 0) return null
|
||||
const parsed = JSON.parse(rows[0].data as string)
|
||||
// Backwards compat: old checkpoints stored a plain array of shapes
|
||||
if (Array.isArray(parsed)) return { shapes: parsed, assets: [], bindings: [] }
|
||||
return {
|
||||
shapes: parsed.shapes ?? [],
|
||||
assets: parsed.assets ?? [],
|
||||
bindings: parsed.bindings ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The last time this session did anything, or null if it never did.
|
||||
*
|
||||
* Tolerating `no such table` is defensive: expiry callbacks run after init()
|
||||
* has created the tables, so a missing one means something unexpected, and
|
||||
* reporting "never active" is the conservative read.
|
||||
*/
|
||||
readLastActivity(): number | null {
|
||||
const sql = this.ctx.storage.sql
|
||||
let lastActivity: number | null = null
|
||||
// Only a missing table means "never active". Any other storage error must
|
||||
// propagate: swallowing it reads as null → infinitely idle → a live session
|
||||
// gets destroyed on a transient storage hiccup.
|
||||
const tableRows = (query: string) => {
|
||||
try {
|
||||
return sql.exec(query).toArray()
|
||||
} catch (err) {
|
||||
if (/no such table/.test(String(err))) return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const meta = tableRows(`SELECT value FROM meta WHERE key = 'lastActivity'`)
|
||||
if (meta.length > 0) {
|
||||
const n = Number(meta[0].value)
|
||||
// A corrupt value must fail toward keep, not toward destroy.
|
||||
lastActivity = Number.isFinite(n) ? n : null
|
||||
}
|
||||
// Legacy DOs predate the lastActivity key; the newest snapshot is the next best signal.
|
||||
if (lastActivity === null) {
|
||||
const cp = tableRows(`SELECT MAX(created_at) AS last FROM checkpoints`)
|
||||
if (cp.length > 0 && cp[0].last != null) {
|
||||
const n = Number(cp[0].last)
|
||||
if (Number.isFinite(n)) lastActivity = n
|
||||
}
|
||||
}
|
||||
return lastActivity
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown without the SDK's trailing `ctx.abort("destroyed")`.
|
||||
*
|
||||
* Cloudflare reclaims an object only when "it shuts down [and] its storage is
|
||||
* empty". An abort is not a shutdown, so aborting straight after deleteAll()
|
||||
* appears to leave the object emptied but still enumerated — and still billed.
|
||||
* Skipping the abort means taking over its other job: severing connections, or a
|
||||
* client-held stream keeps the wiped instance alive.
|
||||
*/
|
||||
override async destroy() {
|
||||
this.selfDestroyed = true
|
||||
const abort = this.ctx.abort.bind(this.ctx)
|
||||
this.ctx.abort = (reason?: string) => {
|
||||
if (reason !== 'destroyed') abort(reason)
|
||||
}
|
||||
for (const conn of this.getConnections()) {
|
||||
// close() throws synchronously on an already-closing socket; a throw here
|
||||
// would wedge teardown before the storage wipe.
|
||||
try {
|
||||
conn.close(1000, 'Session closed')
|
||||
} catch {
|
||||
// already closing
|
||||
}
|
||||
}
|
||||
await super.destroy()
|
||||
}
|
||||
|
||||
/** True once this instance has torn itself down; it must not serve requests after. */
|
||||
private selfDestroyed = false
|
||||
|
||||
override async fetch(request: Request) {
|
||||
if (this.selfDestroyed) return new Response('Session destroyed', { status: 404 })
|
||||
return super.fetch(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* After a schedule callback condemns this object, the SDK re-arms the destroy
|
||||
* alarm with `setAlarm(Date.now())`. Inside an alarm handler `Date.now()` is
|
||||
* frozen until the next I/O, and the handler can start on the scheduled
|
||||
* millisecond itself, so that write can equal the running alarm's time. An
|
||||
* unchanged alarm is a no-op to storage, and the alarm scheduler then drops
|
||||
* the alarm when the handler returns: the object stays condemned forever
|
||||
* (#10709). Moving the alarm a full delay out is always a real change.
|
||||
*/
|
||||
override async alarm() {
|
||||
await super.alarm()
|
||||
if (await this.ctx.storage.get('cf_agents_destroy_pending')) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + DESTROY_ALARM_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Condemns this DO if it has been idle for `maxIdleMs`, and reports whether it
|
||||
* was kept. Never calls destroy() inline: it writes the SDK's own durable
|
||||
* destroy marker, and Agent.alarm() runs destroy() from its marker branch, which
|
||||
* returns before reaching the partyserver alarm body that runs onStart/init().
|
||||
* That keeps this path off `this.name`, unhydrated on that branch alone — an
|
||||
* ordinary schedule callback does go through init. The marker doubles as the
|
||||
* idempotency guard across evictions.
|
||||
*
|
||||
* The setAlarm calls are a fallback. The SDK re-arms after every schedule
|
||||
* callback returns, and that path sees the marker and calls `setAlarm(now)`
|
||||
* itself, so any delay we asked for is overwritten — these writes only matter
|
||||
* if that re-arm never runs. `alarm()` above then moves whichever value won.
|
||||
*/
|
||||
async destroyIfIdle(maxIdleMs: number): Promise<{ kept: boolean }> {
|
||||
if (await this.ctx.storage.get('cf_agents_destroy_pending')) {
|
||||
// Marker without an alarm (setAlarm failed or the invocation died between the
|
||||
// two writes) leaves the DO condemned but never torn down. Re-arm; setAlarm
|
||||
// is idempotent if one is pending.
|
||||
await this.ctx.storage.setAlarm(Date.now())
|
||||
return { kept: false }
|
||||
}
|
||||
const lastActivity = this.readLastActivity()
|
||||
// A DO that never recorded activity is treated as maximally idle.
|
||||
const idleMs = lastActivity === null ? Infinity : Date.now() - lastActivity
|
||||
if (idleMs < maxIdleMs) return { kept: true }
|
||||
this.env.MCP_ANALYTICS?.writeDataPoint({
|
||||
blobs: ['session_end', this.ctx.id.toString()],
|
||||
doubles: [Date.now()],
|
||||
})
|
||||
await this.ctx.storage.put('cf_agents_destroy_pending', true)
|
||||
await this.ctx.storage.setAlarm(Date.now())
|
||||
return { kept: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduler callback. Re-arms WITHOUT `idempotent`: the SDK deletes the
|
||||
* executing schedule row only after this returns, so a deduped re-arm would
|
||||
* match that row, skip, and then lose it — leaving the DO without any future
|
||||
* expiry. On the condemned branch we return without re-arming; the marker
|
||||
* already owns the next alarm. A throw from the check must still fall through
|
||||
* to the re-arm, for the same reason: no re-arm after a throw means no future
|
||||
* check either.
|
||||
*/
|
||||
async expireIfIdle(): Promise<void> {
|
||||
let kept = true
|
||||
let failed = false
|
||||
let lastActivity: number | null = null
|
||||
try {
|
||||
kept = (await this.destroyIfIdle(idleTtlMs(this.env))).kept
|
||||
if (kept) lastActivity = this.readLastActivity()
|
||||
} catch (err) {
|
||||
// Must fall through to the re-arm; see the doc comment above.
|
||||
failed = true
|
||||
console.error('[TldrawMCP] expireIfIdle check failed', this.ctx.id.toString(), err)
|
||||
}
|
||||
if (!kept) return
|
||||
const next = nextExpiryTime({
|
||||
failed,
|
||||
lastActivity,
|
||||
now: Date.now(),
|
||||
ttlMs: idleTtlMs(this.env),
|
||||
})
|
||||
try {
|
||||
await this.schedule(new Date(next), 'expireIfIdle', null)
|
||||
} catch (err) {
|
||||
// Without the id a failed re-arm is an object that silently stops expiring
|
||||
// and cannot be traced back from the logs.
|
||||
console.error('[TldrawMCP] failed to re-arm idle expiry', this.ctx.id.toString(), err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dev helper for idle-expiry.test.ts: inspect the armed expiry schedule row(s). */
|
||||
async listExpirySchedules(): Promise<Array<{ id: string; time: number }>> {
|
||||
const schedules = await this.listSchedules()
|
||||
return schedules
|
||||
.filter((s) => s.callback === 'expireIfIdle')
|
||||
.map((s) => ({ id: s.id, time: s.time }))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fetch handler ---
|
||||
// McpAgent.serve() handles CORS, session management, and transport internally.
|
||||
// Expose both transports: Streamable HTTP at /mcp, SSE at /sse.
|
||||
|
||||
const mcpHandler = TldrawMCP.serve('/mcp')
|
||||
const sseHandler = TldrawMCP.serveSSE('/sse')
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
try {
|
||||
const requireAuth = Boolean(env.MCP_AUTH_TOKEN)
|
||||
const url = new URL(request.url)
|
||||
|
||||
// CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS })
|
||||
}
|
||||
|
||||
// Health check (no auth)
|
||||
if (url.pathname === '/health') {
|
||||
return Response.json({ status: 'ok', timestamp: Date.now() })
|
||||
}
|
||||
|
||||
// Domain verification (no auth)
|
||||
if (url.pathname === '/.well-known/openai-apps-challenge') {
|
||||
return new Response('SG4iyi_lKvsvOJA-QN3UOJZeISqeAf4tnnxqgRMTU0k', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
}
|
||||
|
||||
// Dev-only: map an MCP session id to its TldrawMCP DO id, for poking at a
|
||||
// session's object in the wrangler dev inspector.
|
||||
if (url.pathname === '/admin/do-id' && env.MCP_IS_DEV === 'true') {
|
||||
const session = url.searchParams.get('session') ?? ''
|
||||
return new Response(env.MCP_OBJECT.idFromName(`streamable-http:${session}`).toString())
|
||||
}
|
||||
|
||||
// Dev helper for idle-expiry.test.ts: inspect a session's armed expiry schedule.
|
||||
if (url.pathname === '/admin/schedules' && env.MCP_IS_DEV === 'true') {
|
||||
const session = url.searchParams.get('session') ?? ''
|
||||
const stub = env.MCP_OBJECT.get(env.MCP_OBJECT.idFromName(`streamable-http:${session}`))
|
||||
return Response.json(await stub.listExpirySchedules())
|
||||
}
|
||||
|
||||
// Require bearer auth only when an auth token is configured.
|
||||
if (requireAuth) {
|
||||
const auth = request.headers.get('Authorization')
|
||||
if (auth !== `Bearer ${env.MCP_AUTH_TOKEN}`) {
|
||||
return corsResponse(new Response('Unauthorized', { status: 401 }))
|
||||
}
|
||||
}
|
||||
|
||||
// SSE transport (legacy)
|
||||
if (url.pathname === '/sse' || url.pathname.startsWith('/sse/')) {
|
||||
return sseHandler.fetch(request, env, ctx)
|
||||
}
|
||||
|
||||
// Streamable HTTP transport
|
||||
if (url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) {
|
||||
const sessionId = request.headers.get('mcp-session-id')
|
||||
const forwardedFor = request.headers.get('x-forwarded-for')
|
||||
const clientIp =
|
||||
request.headers.get('cf-connecting-ip') ?? forwardedFor?.split(',')[0]?.trim()
|
||||
const rateLimitKey = sessionId
|
||||
? `mcp-session:${sessionId}`
|
||||
: `mcp-ip:${clientIp ?? 'unknown'}`
|
||||
|
||||
const { success } = await env.RATE_LIMITER.limit({ key: rateLimitKey })
|
||||
if (!success) {
|
||||
return corsResponse(new Response('Rate limited', { status: 429 }))
|
||||
}
|
||||
|
||||
// POST without a session ID is the initial handshake.
|
||||
if (!sessionId && request.method !== 'POST') {
|
||||
return corsResponse(new Response('Missing session', { status: 400 }))
|
||||
}
|
||||
return mcpHandler.fetch(request, env, ctx)
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return new Response('Internal Server Error', { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
134
apps/mcp-app/src/wrangler-dev-harness.ts
Normal file
134
apps/mcp-app/src/wrangler-dev-harness.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const appDir = join(__dirname, '..')
|
||||
|
||||
export interface WranglerDevHandle {
|
||||
base: string
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the real worker under `wrangler dev` for integration tests. The worker
|
||||
* serves dist/ through its assets binding; in CI nothing is built, so callers
|
||||
* pass stand-in fixtures (written only if absent, removed on stop).
|
||||
*/
|
||||
export async function startWranglerDev(opts: {
|
||||
port: number
|
||||
vars?: Record<string, string>
|
||||
fixtures?: Record<string, string>
|
||||
}): Promise<WranglerDevHandle> {
|
||||
const base = `http://127.0.0.1:${opts.port}`
|
||||
const distDir = join(appDir, 'dist')
|
||||
const writtenFixtures: string[] = []
|
||||
mkdirSync(distDir, { recursive: true })
|
||||
for (const [name, contents] of Object.entries(opts.fixtures ?? {})) {
|
||||
const path = join(distDir, name)
|
||||
if (existsSync(path)) continue
|
||||
writeFileSync(path, contents)
|
||||
writtenFixtures.push(path)
|
||||
}
|
||||
|
||||
// wrangler is hoisted to the repo root; resolve its entry through node instead
|
||||
// of assuming a local .bin symlink
|
||||
const wranglerBin = require.resolve('wrangler/bin/wrangler.js')
|
||||
const varArgs = Object.entries({ MCP_IS_DEV: 'true', ...opts.vars }).flatMap(([k, v]) => [
|
||||
'--var',
|
||||
`${k}:${v}`,
|
||||
])
|
||||
const server: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[wranglerBin, 'dev', '--port', String(opts.port), ...varArgs],
|
||||
{
|
||||
cwd: appDir,
|
||||
env: { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
)
|
||||
|
||||
const handle: WranglerDevHandle = {
|
||||
base,
|
||||
stop() {
|
||||
if (server.pid) {
|
||||
// wrangler spawns workerd children; kill the whole detached group
|
||||
try {
|
||||
process.kill(-server.pid, 'SIGTERM')
|
||||
} catch {
|
||||
server.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
for (const path of writtenFixtures) rmSync(path, { force: true })
|
||||
},
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 60_000
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1000) })
|
||||
if (res.ok) break
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
// callers never see a handle on throw, so clean up here or the group leaks
|
||||
handle.stop()
|
||||
throw new Error('wrangler dev did not become ready in 60s')
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Minimal assets so init() can load the widget and a canvas read resolves. */
|
||||
export const MINIMAL_FIXTURES = {
|
||||
'mcp-app.html': '<!doctype html><html><body>stub</body></html>',
|
||||
'method-map.json': JSON.stringify({ select: { args: ['spread-ids'], ret: 'this' } }),
|
||||
}
|
||||
|
||||
export function mcpPost(base: string, body: unknown, sessionId?: string) {
|
||||
return fetch(`${base}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
...(sessionId ? { 'mcp-session-id': sessionId } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// generous for CI, but far below an infinite hang
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
}
|
||||
|
||||
/** The response arrives as an SSE stream; pull the JSON out of its `data:` line. */
|
||||
export function parseSSEResult(text: string) {
|
||||
const data = text
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data: '))
|
||||
.map((line) => line.slice('data: '.length))
|
||||
.join('')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
|
||||
export async function initSession(base: string, clientName: string): Promise<string> {
|
||||
const initRes = await mcpPost(base, {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: clientName, version: '1.0' },
|
||||
},
|
||||
})
|
||||
if (initRes.status !== 200) throw new Error(`initialize failed: ${initRes.status}`)
|
||||
const sessionId = initRes.headers.get('mcp-session-id')
|
||||
if (!sessionId) throw new Error('initialize returned no mcp-session-id')
|
||||
await initRes.text()
|
||||
await mcpPost(base, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId)
|
||||
return sessionId
|
||||
}
|
||||
23
apps/mcp-app/tsconfig.json
Normal file
23
apps/mcp-app/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react-jsx",
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "vitest.config.ts", "scripts/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/tldraw"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
apps/mcp-app/vite.config.ts
Normal file
13
apps/mcp-app/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile()],
|
||||
root: 'src/widget',
|
||||
envDir: '../..',
|
||||
build: {
|
||||
outDir: '../../dist',
|
||||
emptyOutDir: false,
|
||||
},
|
||||
})
|
||||
18
apps/mcp-app/vitest.config.ts
Normal file
18
apps/mcp-app/vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// The vite config sets `root: 'src/widget'` for the singlefile widget build,
|
||||
// which would scope test discovery to the widget directory. Tests live across
|
||||
// `src/`, so give vitest its own config.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
fsModuleCache: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
// Two files boot their own `wrangler dev` and write/remove dist/ fixtures; in
|
||||
// parallel they race on those files and one of them reads the other's stub.
|
||||
fileParallelism: false,
|
||||
// fileParallelism pins this project to one worker. A normal run tolerates that, but
|
||||
// `vitest doctor` errors on projects that share a group order with differing worker
|
||||
// counts, so give this one its own. Measured to cost nothing on a full run.
|
||||
sequence: { groupOrder: 1 },
|
||||
},
|
||||
})
|
||||
62
apps/mcp-app/wrangler.toml
Normal file
62
apps/mcp-app/wrangler.toml
Normal file
@@ -0,0 +1,62 @@
|
||||
name = "tldraw-mcp-app"
|
||||
main = "src/worker.ts"
|
||||
# Keep in sync with WORKER_COMPATIBILITY_DATE in src/shared/types.ts
|
||||
compatibility_date = "2025-03-10"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
preview_urls = true
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
binding = "ASSETS"
|
||||
run_worker_first = true
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ class_name = "TldrawMCP", name = "MCP_OBJECT" },
|
||||
{ class_name = "CanvasStore", name = "CANVAS_STORE" },
|
||||
]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_sqlite_classes = ["TldrawMCP"]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v2"
|
||||
new_sqlite_classes = ["CanvasStore"]
|
||||
|
||||
[vars]
|
||||
# Set this to the public URL of the worker. Used for constructing absolute image URLs.
|
||||
# Override with `--var WORKER_ORIGIN:https://your-tunnel.trycloudflare.com` for cloudflared tunnels.
|
||||
WORKER_ORIGIN = "https://tldraw-mcp-app.tldraw.workers.dev"
|
||||
# Default to production-safe widget behavior. Local dev scripts override this with
|
||||
# `--var MCP_IS_DEV:true` so local HTTP connectors omit `ui.domain`.
|
||||
MCP_IS_DEV = "false"
|
||||
|
||||
[[analytics_engine_datasets]]
|
||||
binding = "MCP_ANALYTICS"
|
||||
dataset = "MCP_ANALYTICS"
|
||||
|
||||
[[worker_loaders]]
|
||||
binding = "LOADER"
|
||||
|
||||
#################### Rate limiting ####################
|
||||
[[ratelimits]]
|
||||
name = "RATE_LIMITER"
|
||||
namespace_id = "2001"
|
||||
|
||||
[ratelimits.simple]
|
||||
limit = 30
|
||||
period = 60
|
||||
|
||||
#################### Observability ####################
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
# Invocation logs record every invocation, including ones that produce no output
|
||||
# of their own. The Agents SDK spends three Durable Object invocations per MCP
|
||||
# request on session routing before any work happens (a `set-name` fetch, plus
|
||||
# `getInitializeRequest` and `updateProps` RPCs), so those empty records were
|
||||
# roughly 80% of this worker's log volume. Turning them off keeps `console`
|
||||
# output — head sampling would drop real errors along with the noise.
|
||||
[observability.logs]
|
||||
invocation_logs = false
|
||||
Reference in New Issue
Block a user