pmx-canvas 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/dist/canvas/index.js +34 -34
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/server/canvas-operations.d.ts +8 -0
- package/dist/types/server/diagram-presets.d.ts +4 -0
- package/dist/types/server/index.d.ts +8 -0
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/package.json +1 -1
- package/src/cli/agent.ts +15 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
- package/src/client/nodes/ExtAppFrame.tsx +10 -35
- package/src/client/nodes/McpAppNode.tsx +2 -2
- package/src/client/state/sse-bridge.ts +1 -0
- package/src/mcp/server.ts +21 -1
- package/src/server/canvas-operations.ts +35 -0
- package/src/server/diagram-presets.ts +45 -25
- package/src/server/index.ts +31 -4
- package/src/server/mcp-app-runtime.ts +15 -5
- package/src/server/server.ts +95 -50
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
3
3
|
import type { CanvasNodeState } from '../types';
|
|
4
|
-
type IframeLoadTarget = Pick<HTMLIFrameElement, 'addEventListener' | 'removeEventListener' | 'contentDocument'>;
|
|
5
4
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
6
5
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
7
6
|
interface ExtAppHostDimensionsTarget {
|
|
@@ -9,7 +8,6 @@ interface ExtAppHostDimensionsTarget {
|
|
|
9
8
|
clientHeight?: number;
|
|
10
9
|
getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
|
|
11
10
|
}
|
|
12
|
-
export declare function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void>;
|
|
13
11
|
export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
|
|
14
12
|
export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
|
|
15
13
|
nextMode: DisplayMode;
|
|
@@ -26,7 +24,8 @@ export declare function resolveExtAppContainerDimensions(target: ExtAppHostDimen
|
|
|
26
24
|
height: number;
|
|
27
25
|
};
|
|
28
26
|
export declare function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean): height is number;
|
|
29
|
-
export declare function ExtAppFrame({ node }: {
|
|
27
|
+
export declare function ExtAppFrame({ node, expanded }: {
|
|
30
28
|
node: CanvasNodeState;
|
|
29
|
+
expanded?: boolean;
|
|
31
30
|
}): import("preact/jsx-runtime").JSX.Element;
|
|
32
31
|
export {};
|
|
@@ -40,6 +40,12 @@ interface CanvasAddNodeInput {
|
|
|
40
40
|
title?: string;
|
|
41
41
|
content?: string;
|
|
42
42
|
data?: Record<string, unknown>;
|
|
43
|
+
toolName?: string;
|
|
44
|
+
category?: string;
|
|
45
|
+
status?: string;
|
|
46
|
+
duration?: string;
|
|
47
|
+
resultSummary?: string;
|
|
48
|
+
error?: string;
|
|
43
49
|
x?: number;
|
|
44
50
|
y?: number;
|
|
45
51
|
width?: number;
|
|
@@ -106,6 +112,8 @@ export declare function validateCanvasNodePatch(patch: {
|
|
|
106
112
|
height: number;
|
|
107
113
|
};
|
|
108
114
|
}): string | null;
|
|
115
|
+
export declare function mergeTraceNodeDataFields(base: Record<string, unknown>, input: Record<string, unknown>): Record<string, unknown>;
|
|
116
|
+
export declare function hasTraceNodeDataFields(input: Record<string, unknown>): boolean;
|
|
109
117
|
export declare function scheduleCodeGraphRecompute(onComplete?: () => void): void;
|
|
110
118
|
export declare function addCanvasNode(input: CanvasAddNodeInput): {
|
|
111
119
|
id: string;
|
|
@@ -9,11 +9,13 @@ export declare const DEFAULT_EXCALIDRAW_ELEMENTS: ReadonlyArray<Record<string, u
|
|
|
9
9
|
export declare const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig;
|
|
10
10
|
export interface DiagramPresetOpenInput {
|
|
11
11
|
elements: unknown;
|
|
12
|
+
nodeId?: string;
|
|
12
13
|
title?: string;
|
|
13
14
|
x?: number;
|
|
14
15
|
y?: number;
|
|
15
16
|
width?: number;
|
|
16
17
|
height?: number;
|
|
18
|
+
timeoutMs?: number;
|
|
17
19
|
}
|
|
18
20
|
export interface ExcalidrawOpenMcpAppInput {
|
|
19
21
|
transport: ExternalMcpTransportConfig;
|
|
@@ -22,11 +24,13 @@ export interface ExcalidrawOpenMcpAppInput {
|
|
|
22
24
|
toolArguments: {
|
|
23
25
|
elements: string;
|
|
24
26
|
};
|
|
27
|
+
nodeId?: string;
|
|
25
28
|
title?: string;
|
|
26
29
|
x?: number;
|
|
27
30
|
y?: number;
|
|
28
31
|
width?: number;
|
|
29
32
|
height?: number;
|
|
33
|
+
timeoutMs?: number;
|
|
30
34
|
}
|
|
31
35
|
export declare function inferExcalidrawCameraUpdate(elements: Array<Record<string, unknown>>): Record<string, unknown> | null;
|
|
32
36
|
export declare function normalizeExcalidrawElements(elements: unknown): string;
|
|
@@ -23,6 +23,12 @@ export declare class PmxCanvas extends EventEmitter {
|
|
|
23
23
|
type: CanvasNodeState['type'];
|
|
24
24
|
title?: string;
|
|
25
25
|
content?: string;
|
|
26
|
+
toolName?: string;
|
|
27
|
+
category?: string;
|
|
28
|
+
status?: string;
|
|
29
|
+
duration?: string;
|
|
30
|
+
resultSummary?: string;
|
|
31
|
+
error?: string;
|
|
26
32
|
x?: number;
|
|
27
33
|
y?: number;
|
|
28
34
|
width?: number;
|
|
@@ -198,12 +204,14 @@ export declare class PmxCanvas extends EventEmitter {
|
|
|
198
204
|
transport: ExternalMcpTransportConfig;
|
|
199
205
|
toolName: string;
|
|
200
206
|
toolArguments?: Record<string, unknown>;
|
|
207
|
+
nodeId?: string;
|
|
201
208
|
serverName?: string;
|
|
202
209
|
title?: string;
|
|
203
210
|
x?: number;
|
|
204
211
|
y?: number;
|
|
205
212
|
width?: number;
|
|
206
213
|
height?: number;
|
|
214
|
+
timeoutMs?: number;
|
|
207
215
|
}): Promise<{
|
|
208
216
|
ok: true;
|
|
209
217
|
id?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
package/src/cli/agent.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
|
|
27
27
|
const DEFAULT_PORT = 4313;
|
|
28
28
|
const defaultConsoleLog = console.log;
|
|
29
|
+
const TRACE_NODE_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
|
|
29
30
|
|
|
30
31
|
interface CanvasSchemaField {
|
|
31
32
|
name: string;
|
|
@@ -1078,6 +1079,12 @@ cmd('node add', 'Add a node to the canvas', [
|
|
|
1078
1079
|
height: 'Use a positive number, e.g. --height 280',
|
|
1079
1080
|
});
|
|
1080
1081
|
applyStrictSizeFlags(body, flags);
|
|
1082
|
+
if (type === 'trace') {
|
|
1083
|
+
for (const field of TRACE_NODE_FIELDS) {
|
|
1084
|
+
const value = getStringFlag(flags, field);
|
|
1085
|
+
if (value !== undefined) body[field] = value;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1081
1088
|
|
|
1082
1089
|
// Support --stdin for piping content
|
|
1083
1090
|
if (flags.stdin) {
|
|
@@ -1565,7 +1572,9 @@ cmd('external-app add', 'Create a hosted external app node', [
|
|
|
1565
1572
|
title: typeof flags.title === 'string' ? flags.title : 'Excalidraw Diagram',
|
|
1566
1573
|
elements: DEFAULT_EXCALIDRAW_ELEMENTS,
|
|
1567
1574
|
};
|
|
1568
|
-
const
|
|
1575
|
+
const nodeId = getStringFlag(flags, 'node-id', 'nodeId', 'id');
|
|
1576
|
+
if (nodeId) body.nodeId = nodeId;
|
|
1577
|
+
const elementsJson = getStringFlag(flags, 'elements-json', 'elements');
|
|
1569
1578
|
if (elementsJson !== undefined) body.elements = parseJsonValue(elementsJson, 'Excalidraw elements', 'Use --elements-json \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'');
|
|
1570
1579
|
const elementsFile = getStringFlag(flags, 'elements-file', 'initial-file');
|
|
1571
1580
|
if (elementsFile) body.elements = parseJsonValue(readFileSync(elementsFile, 'utf-8'), 'Excalidraw elements file', 'Use --elements-file ./scene.excalidraw');
|
|
@@ -1575,6 +1584,8 @@ cmd('external-app add', 'Create a hosted external app node', [
|
|
|
1575
1584
|
width: 'Use a positive number, e.g. --width 960',
|
|
1576
1585
|
height: 'Use a positive number, e.g. --height 720',
|
|
1577
1586
|
});
|
|
1587
|
+
const timeoutMs = optionalPositiveFiniteFlag(flags, 'timeout-ms', 'Use a positive number, e.g. --timeout-ms 120000');
|
|
1588
|
+
if (timeoutMs !== undefined) body.timeoutMs = timeoutMs;
|
|
1578
1589
|
|
|
1579
1590
|
const result = await api('POST', '/api/canvas/diagram', body);
|
|
1580
1591
|
output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
|
|
@@ -2303,9 +2314,12 @@ function showCommandHelp(name: string): void {
|
|
|
2303
2314
|
console.log('\nOptions:');
|
|
2304
2315
|
console.log(' --kind excalidraw External app kind to create');
|
|
2305
2316
|
console.log(' --title <title> Node title');
|
|
2317
|
+
console.log(' --node-id <id> Existing Excalidraw app node to update in place');
|
|
2318
|
+
console.log(' --elements <json> Optional Excalidraw elements array JSON');
|
|
2306
2319
|
console.log(' --elements-json <json> Optional Excalidraw elements array JSON');
|
|
2307
2320
|
console.log(' --elements-file <path> Optional file containing Excalidraw elements JSON');
|
|
2308
2321
|
console.log(' --initial-file <path> Alias for --elements-file');
|
|
2322
|
+
console.log(' --timeout-ms <number> Optional downstream MCP timeout for cold starts');
|
|
2309
2323
|
}
|
|
2310
2324
|
console.log('');
|
|
2311
2325
|
}
|
|
@@ -26,13 +26,13 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
|
|
|
26
26
|
case 'markdown':
|
|
27
27
|
return <MarkdownNode node={node} expanded={expanded} />;
|
|
28
28
|
case 'mcp-app':
|
|
29
|
-
return <McpAppNode node={node} />;
|
|
29
|
+
return <McpAppNode node={node} expanded={expanded} />;
|
|
30
30
|
case 'webpage':
|
|
31
31
|
return <WebpageNode node={node} expanded={expanded} />;
|
|
32
32
|
case 'json-render':
|
|
33
|
-
return <McpAppNode node={node} />;
|
|
33
|
+
return <McpAppNode node={node} expanded={expanded} />;
|
|
34
34
|
case 'graph':
|
|
35
|
-
return <McpAppNode node={node} />;
|
|
35
|
+
return <McpAppNode node={node} expanded={expanded} />;
|
|
36
36
|
case 'prompt':
|
|
37
37
|
return <PromptNode node={node} />;
|
|
38
38
|
case 'response':
|
|
@@ -13,11 +13,6 @@ import type { CanvasNodeState } from '../types';
|
|
|
13
13
|
|
|
14
14
|
type McpUiTheme = 'light' | 'dark';
|
|
15
15
|
|
|
16
|
-
type IframeLoadTarget = Pick<
|
|
17
|
-
HTMLIFrameElement,
|
|
18
|
-
'addEventListener' | 'removeEventListener' | 'contentDocument'
|
|
19
|
-
>;
|
|
20
|
-
|
|
21
16
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
22
17
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
23
18
|
const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
|
@@ -43,21 +38,6 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
|
|
|
43
38
|
return json.result as T;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
export function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void> {
|
|
47
|
-
const readyState = target.contentDocument?.readyState;
|
|
48
|
-
if (readyState === 'interactive' || readyState === 'complete') {
|
|
49
|
-
return Promise.resolve();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return new Promise<void>((resolve) => {
|
|
53
|
-
const onLoad = () => {
|
|
54
|
-
target.removeEventListener('load', onLoad);
|
|
55
|
-
resolve();
|
|
56
|
-
};
|
|
57
|
-
target.addEventListener('load', onLoad, { once: true });
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
41
|
export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
|
|
62
42
|
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
63
43
|
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
@@ -131,7 +111,7 @@ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean
|
|
|
131
111
|
return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
|
|
132
112
|
}
|
|
133
113
|
|
|
134
|
-
export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
114
|
+
export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
135
115
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
136
116
|
const bridgeRef = useRef<AppBridge | null>(null);
|
|
137
117
|
const transportRef = useRef<PostMessageTransport | null>(null);
|
|
@@ -164,10 +144,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
164
144
|
const sessionError = node.data.sessionError as string | undefined;
|
|
165
145
|
const maxHeight = node.size.height;
|
|
166
146
|
const nodeId = node.id;
|
|
167
|
-
const frameKey =
|
|
168
|
-
const bridgeInitKey = getExtAppBridgeInitKey(node, retryKey);
|
|
147
|
+
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
169
148
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
170
|
-
const isExpanded = expandedNodeId.value === nodeId;
|
|
149
|
+
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
171
150
|
|
|
172
151
|
latestToolInputRef.current = toolInput;
|
|
173
152
|
latestToolResultRef.current = toolResult;
|
|
@@ -221,9 +200,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
221
200
|
return sendPromise;
|
|
222
201
|
};
|
|
223
202
|
|
|
224
|
-
// Initialize
|
|
203
|
+
// Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
|
|
225
204
|
useEffect(() => {
|
|
226
|
-
if (!html) return;
|
|
205
|
+
if (!html) return;
|
|
227
206
|
const iframe = iframeRef.current;
|
|
228
207
|
if (!iframe) return;
|
|
229
208
|
let disposed = false;
|
|
@@ -242,12 +221,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
242
221
|
};
|
|
243
222
|
|
|
244
223
|
const init = async () => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
await waitForExtAppFrameLoad(iframe);
|
|
248
|
-
if (disposed) return;
|
|
249
|
-
contentWindow = iframe.contentWindow;
|
|
250
|
-
}
|
|
224
|
+
if (!html) return;
|
|
225
|
+
const contentWindow = iframe.contentWindow;
|
|
251
226
|
if (!contentWindow) {
|
|
252
227
|
throw new Error('Ext-app iframe window is unavailable');
|
|
253
228
|
}
|
|
@@ -397,8 +372,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
397
372
|
bridgeReadyRef.current = true;
|
|
398
373
|
setStatus('ready');
|
|
399
374
|
setError(null);
|
|
400
|
-
|
|
401
|
-
|
|
375
|
+
void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
|
|
376
|
+
.then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
|
|
402
377
|
.then(() => flushToolResult(bridge))
|
|
403
378
|
.catch((err) => {
|
|
404
379
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -481,7 +456,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
481
456
|
transportRef.current = null;
|
|
482
457
|
}
|
|
483
458
|
};
|
|
484
|
-
}, [
|
|
459
|
+
}, [frameKey]);
|
|
485
460
|
|
|
486
461
|
// Forward tool result when it arrives after bridge is ready
|
|
487
462
|
useEffect(() => {
|
|
@@ -13,9 +13,9 @@ function withTheme(url: string): string {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function McpAppNode({ node }: { node: CanvasNodeState }) {
|
|
16
|
+
export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
17
17
|
if (node.data.mode === 'ext-app') {
|
|
18
|
-
return <ExtAppFrame node={node} />;
|
|
18
|
+
return <ExtAppFrame node={node} expanded={expanded} />;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const url = withTheme((node.data.url as string) || '');
|
|
@@ -529,6 +529,7 @@ function handleExtAppOpen(data: Record<string, unknown>): void {
|
|
|
529
529
|
if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
|
|
530
530
|
ensureExtAppNode({
|
|
531
531
|
toolCallId: data.toolCallId,
|
|
532
|
+
...(typeof data.nodeId === 'string' && data.nodeId.length > 0 ? { nodeId: data.nodeId } : {}),
|
|
532
533
|
title: data.title,
|
|
533
534
|
html: data.html,
|
|
534
535
|
toolInput: data.toolInput,
|
package/src/mcp/server.ts
CHANGED
|
@@ -261,6 +261,12 @@ export async function startMcpServer(): Promise<void> {
|
|
|
261
261
|
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
262
262
|
height: z.number().optional().describe('Height in pixels (default: 600)'),
|
|
263
263
|
strictSize: z.boolean().optional().describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
|
|
264
|
+
toolName: z.string().optional().describe('Trace node tool or operation label'),
|
|
265
|
+
category: z.string().optional().describe('Trace node category: mcp, file, subagent, or other'),
|
|
266
|
+
status: z.string().optional().describe('Trace node status: running, success, or failed'),
|
|
267
|
+
duration: z.string().optional().describe('Trace node duration badge text'),
|
|
268
|
+
resultSummary: z.string().optional().describe('Trace node result summary'),
|
|
269
|
+
error: z.string().optional().describe('Trace node error message'),
|
|
264
270
|
},
|
|
265
271
|
async (input) => {
|
|
266
272
|
const c = await ensureCanvas();
|
|
@@ -303,11 +309,13 @@ export async function startMcpServer(): Promise<void> {
|
|
|
303
309
|
toolName: z.string().describe('Tool name on the external MCP server'),
|
|
304
310
|
serverName: z.string().optional().describe('Optional display name for the external MCP server'),
|
|
305
311
|
toolArguments: z.record(z.string(), z.unknown()).optional().describe('Arguments passed to the external tool call'),
|
|
312
|
+
nodeId: z.string().optional().describe('Existing mcp-app node ID to update in place instead of creating a new node.'),
|
|
306
313
|
title: z.string().optional().describe('Optional canvas node title override'),
|
|
307
314
|
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
308
315
|
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
309
316
|
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
310
317
|
height: z.number().optional().describe('Height in pixels (default: 500)'),
|
|
318
|
+
timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for cold external app servers'),
|
|
311
319
|
transport: z.union([
|
|
312
320
|
z.object({
|
|
313
321
|
type: z.literal('stdio'),
|
|
@@ -331,11 +339,13 @@ export async function startMcpServer(): Promise<void> {
|
|
|
331
339
|
toolName: input.toolName,
|
|
332
340
|
...(typeof input.serverName === 'string' ? { serverName: input.serverName } : {}),
|
|
333
341
|
...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
|
|
342
|
+
...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
|
|
334
343
|
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
335
344
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
336
345
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
337
346
|
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
338
347
|
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
348
|
+
...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
|
|
339
349
|
});
|
|
340
350
|
return {
|
|
341
351
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
@@ -357,27 +367,37 @@ export async function startMcpServer(): Promise<void> {
|
|
|
357
367
|
z.string().describe('JSON array string of Excalidraw elements'),
|
|
358
368
|
z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
|
|
359
369
|
]).describe('Excalidraw elements to render. See https://github.com/excalidraw/excalidraw-mcp for the element format.'),
|
|
370
|
+
nodeId: z.string().optional().describe('Existing Excalidraw mcp-app node ID to update in place instead of creating a new node.'),
|
|
360
371
|
title: z.string().optional().describe('Optional canvas node title override'),
|
|
361
372
|
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
362
373
|
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
363
374
|
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
364
375
|
height: z.number().optional().describe('Height in pixels (default: 500)'),
|
|
376
|
+
timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for Excalidraw cold starts. Client-side MCP hosts may still enforce their own total request timeout.'),
|
|
365
377
|
},
|
|
366
|
-
async (input) => {
|
|
378
|
+
async (input, extra) => {
|
|
367
379
|
const c = await ensureCanvas();
|
|
368
380
|
try {
|
|
369
381
|
const result = await c.addDiagram({
|
|
370
382
|
elements: input.elements,
|
|
383
|
+
...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
|
|
371
384
|
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
372
385
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
373
386
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
374
387
|
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
375
388
|
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
389
|
+
...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
|
|
376
390
|
});
|
|
377
391
|
return {
|
|
378
392
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
379
393
|
};
|
|
380
394
|
} catch (error) {
|
|
395
|
+
if (extra.signal.aborted) {
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: 'text', text: 'canvas_add_diagram was cancelled by the MCP client before Excalidraw finished. Retry with a higher client request timeout and pass timeoutMs to PMX Canvas for the downstream Excalidraw call.' }],
|
|
398
|
+
isError: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
381
401
|
return {
|
|
382
402
|
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
383
403
|
isError: true,
|
|
@@ -76,6 +76,12 @@ interface CanvasAddNodeInput {
|
|
|
76
76
|
title?: string;
|
|
77
77
|
content?: string;
|
|
78
78
|
data?: Record<string, unknown>;
|
|
79
|
+
toolName?: string;
|
|
80
|
+
category?: string;
|
|
81
|
+
status?: string;
|
|
82
|
+
duration?: string;
|
|
83
|
+
resultSummary?: string;
|
|
84
|
+
error?: string;
|
|
79
85
|
x?: number;
|
|
80
86
|
y?: number;
|
|
81
87
|
width?: number;
|
|
@@ -109,6 +115,7 @@ interface CanvasNodeLookupInput {
|
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
const MAX_CONTEXT_PINS = 20;
|
|
118
|
+
const TRACE_DATA_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
|
|
112
119
|
|
|
113
120
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
114
121
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -757,10 +764,23 @@ function buildWebpageNodeData(input: CanvasAddNodeInput): Record<string, unknown
|
|
|
757
764
|
};
|
|
758
765
|
}
|
|
759
766
|
|
|
767
|
+
function normalizeTraceNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
768
|
+
const data: Record<string, unknown> = { ...(input.data ?? {}) };
|
|
769
|
+
for (const field of TRACE_DATA_FIELDS) {
|
|
770
|
+
const value = input[field];
|
|
771
|
+
if (typeof value === 'string') data[field] = value;
|
|
772
|
+
}
|
|
773
|
+
if (input.title) data.title = input.title;
|
|
774
|
+
if (input.content) data.content = input.content;
|
|
775
|
+
if (input.strictSize) data.strictSize = true;
|
|
776
|
+
return data;
|
|
777
|
+
}
|
|
778
|
+
|
|
760
779
|
function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
761
780
|
if (input.type === 'file') return buildFileNodeData(input);
|
|
762
781
|
if (input.type === 'image') return buildImageNodeData(input);
|
|
763
782
|
if (input.type === 'webpage') return buildWebpageNodeData(input);
|
|
783
|
+
if (input.type === 'trace') return normalizeTraceNodeData(input);
|
|
764
784
|
return {
|
|
765
785
|
...(input.data ?? {}),
|
|
766
786
|
...(input.title ? { title: input.title } : {}),
|
|
@@ -769,6 +789,21 @@ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
|
|
|
769
789
|
};
|
|
770
790
|
}
|
|
771
791
|
|
|
792
|
+
export function mergeTraceNodeDataFields(
|
|
793
|
+
base: Record<string, unknown>,
|
|
794
|
+
input: Record<string, unknown>,
|
|
795
|
+
): Record<string, unknown> {
|
|
796
|
+
const next: Record<string, unknown> = { ...base };
|
|
797
|
+
for (const field of TRACE_DATA_FIELDS) {
|
|
798
|
+
if (typeof input[field] === 'string') next[field] = input[field];
|
|
799
|
+
}
|
|
800
|
+
return next;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export function hasTraceNodeDataFields(input: Record<string, unknown>): boolean {
|
|
804
|
+
return TRACE_DATA_FIELDS.some((field) => typeof input[field] === 'string');
|
|
805
|
+
}
|
|
806
|
+
|
|
772
807
|
export function scheduleCodeGraphRecompute(onComplete?: () => void): void {
|
|
773
808
|
if (codeGraphTimer) clearTimeout(codeGraphTimer);
|
|
774
809
|
codeGraphTimer = setTimeout(() => {
|
|
@@ -43,11 +43,13 @@ export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
|
|
|
43
43
|
|
|
44
44
|
export interface DiagramPresetOpenInput {
|
|
45
45
|
elements: unknown;
|
|
46
|
+
nodeId?: string;
|
|
46
47
|
title?: string;
|
|
47
48
|
x?: number;
|
|
48
49
|
y?: number;
|
|
49
50
|
width?: number;
|
|
50
51
|
height?: number;
|
|
52
|
+
timeoutMs?: number;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export interface ExcalidrawOpenMcpAppInput {
|
|
@@ -55,11 +57,13 @@ export interface ExcalidrawOpenMcpAppInput {
|
|
|
55
57
|
toolName: string;
|
|
56
58
|
serverName: string;
|
|
57
59
|
toolArguments: { elements: string };
|
|
60
|
+
nodeId?: string;
|
|
58
61
|
title?: string;
|
|
59
62
|
x?: number;
|
|
60
63
|
y?: number;
|
|
61
64
|
width?: number;
|
|
62
65
|
height?: number;
|
|
66
|
+
timeoutMs?: number;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -111,6 +115,21 @@ function finiteNumber(value: unknown): number | null {
|
|
|
111
115
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
function positiveFiniteNumber(value: unknown): number | null {
|
|
119
|
+
const num = finiteNumber(value);
|
|
120
|
+
return num !== null && num > 0 ? num : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function labelFromBoundText(element: Record<string, unknown>): Record<string, unknown> | null {
|
|
124
|
+
const text = typeof element.text === 'string' ? element.text : '';
|
|
125
|
+
if (text.trim().length === 0) return null;
|
|
126
|
+
const fontSize = positiveFiniteNumber(element.fontSize);
|
|
127
|
+
return {
|
|
128
|
+
text,
|
|
129
|
+
...(fontSize ? { fontSize } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
|
|
115
134
|
return elements.some((element) => element.type === 'cameraUpdate');
|
|
116
135
|
}
|
|
@@ -134,39 +153,38 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
|
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
let changed = false;
|
|
137
|
-
const
|
|
156
|
+
const labelsByContainer = new Map<string, Record<string, unknown>>();
|
|
138
157
|
|
|
139
158
|
for (const element of elements) {
|
|
140
159
|
if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
|
|
141
160
|
const container = elementsById.get(element.containerId);
|
|
142
|
-
if (!container) continue;
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
161
|
+
if (!container || (container.type !== 'rectangle' && container.type !== 'ellipse' && container.type !== 'diamond')) continue;
|
|
162
|
+
const label = labelFromBoundText(element);
|
|
163
|
+
if (!label) continue;
|
|
164
|
+
labelsByContainer.set(element.containerId, label);
|
|
146
165
|
}
|
|
147
166
|
|
|
148
|
-
const normalized
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const missing = [...boundTextIds].filter((id) => !existingTextIds.has(id));
|
|
162
|
-
if (missing.length === 0) return element;
|
|
167
|
+
const normalized: Array<Record<string, unknown>> = [];
|
|
168
|
+
for (const element of elements) {
|
|
169
|
+
if (element.type === 'text' && typeof element.containerId === 'string') {
|
|
170
|
+
if (labelsByContainer.has(element.containerId)) {
|
|
171
|
+
changed = true;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof element.id !== 'string' || !labelsByContainer.has(element.id)) {
|
|
177
|
+
normalized.push(element);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
163
180
|
|
|
164
181
|
changed = true;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
const { boundElements: _boundElements, ...container } = element;
|
|
183
|
+
normalized.push({
|
|
184
|
+
...container,
|
|
185
|
+
label: labelsByContainer.get(element.id),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
170
188
|
|
|
171
189
|
return changed ? normalized : elements;
|
|
172
190
|
}
|
|
@@ -333,10 +351,12 @@ export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): E
|
|
|
333
351
|
serverName: EXCALIDRAW_SERVER_NAME,
|
|
334
352
|
toolArguments: { elements },
|
|
335
353
|
};
|
|
354
|
+
if (typeof input.nodeId === 'string' && input.nodeId.trim().length > 0) out.nodeId = input.nodeId.trim();
|
|
336
355
|
if (typeof input.title === 'string' && input.title.trim().length > 0) out.title = input.title.trim();
|
|
337
356
|
if (typeof input.x === 'number' && Number.isFinite(input.x)) out.x = input.x;
|
|
338
357
|
if (typeof input.y === 'number' && Number.isFinite(input.y)) out.y = input.y;
|
|
339
358
|
if (typeof input.width === 'number' && Number.isFinite(input.width)) out.width = input.width;
|
|
340
359
|
if (typeof input.height === 'number' && Number.isFinite(input.height)) out.height = input.height;
|
|
360
|
+
if (typeof input.timeoutMs === 'number' && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0) out.timeoutMs = input.timeoutMs;
|
|
341
361
|
return out;
|
|
342
362
|
}
|