pmx-canvas 0.1.12 → 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 +151 -0
- package/dist/canvas/index.js +42 -42
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/nodes/trace-model.d.ts +9 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -0
- 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/dist/types/server/web-artifacts.d.ts +1 -0
- package/package.json +1 -1
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +9 -8
- 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/nodes/TraceNode.tsx +2 -6
- package/src/client/nodes/trace-model.ts +19 -0
- package/src/client/state/canvas-store.ts +5 -2
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/mcp/canvas-access.ts +28 -3
- package/src/mcp/server.ts +51 -9
- package/src/server/canvas-operations.ts +36 -0
- package/src/server/canvas-schema.ts +11 -0
- package/src/server/diagram-presets.ts +44 -46
- package/src/server/index.ts +31 -4
- package/src/server/mcp-app-runtime.ts +15 -5
- package/src/server/server.ts +96 -50
- package/src/server/web-artifacts/scripts/init-artifact.sh +9 -8
- package/src/server/web-artifacts.ts +14 -1
package/src/mcp/server.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
25
25
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
|
|
28
|
-
import { createCanvasAccess, type CanvasAccess } from './canvas-access.js';
|
|
28
|
+
import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
|
|
29
29
|
import { serializeNodeForAgentContext } from '../server/agent-context.js';
|
|
30
30
|
import { wrapCanvasAutomationScript } from '../server/server.js';
|
|
31
31
|
import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
|
|
@@ -34,7 +34,8 @@ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js
|
|
|
34
34
|
|
|
35
35
|
let canvas: CanvasAccess | null = null;
|
|
36
36
|
let resourceNotificationServer: McpServer | null = null;
|
|
37
|
-
let
|
|
37
|
+
let localResourceNotificationsStarted = false;
|
|
38
|
+
let remoteResourceNotificationsBaseUrl: string | null = null;
|
|
38
39
|
|
|
39
40
|
const jsonRenderSpecSchema = z.union([
|
|
40
41
|
z.object({
|
|
@@ -78,6 +79,8 @@ function safeWorkspacePath(pathLike: string): string {
|
|
|
78
79
|
async function ensureCanvas(): Promise<CanvasAccess> {
|
|
79
80
|
if (!canvas) {
|
|
80
81
|
canvas = await createCanvasAccess();
|
|
82
|
+
} else {
|
|
83
|
+
canvas = await refreshCanvasAccess(canvas);
|
|
81
84
|
}
|
|
82
85
|
startResourceNotifications(canvas);
|
|
83
86
|
return canvas;
|
|
@@ -139,16 +142,20 @@ async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
|
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
function startResourceNotifications(c: CanvasAccess): void {
|
|
142
|
-
if (resourceNotificationsStarted) return;
|
|
143
145
|
const server = resourceNotificationServer;
|
|
144
146
|
if (!server) return;
|
|
145
|
-
resourceNotificationsStarted = true;
|
|
146
147
|
|
|
147
148
|
if (c.remoteBaseUrl) {
|
|
148
|
-
|
|
149
|
+
if (remoteResourceNotificationsBaseUrl !== c.remoteBaseUrl) {
|
|
150
|
+
remoteResourceNotificationsBaseUrl = c.remoteBaseUrl;
|
|
151
|
+
void watchRemoteCanvasEvents(c.remoteBaseUrl);
|
|
152
|
+
}
|
|
149
153
|
return;
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
if (localResourceNotificationsStarted) return;
|
|
157
|
+
localResourceNotificationsStarted = true;
|
|
158
|
+
|
|
152
159
|
canvasState.onChange((type) => {
|
|
153
160
|
sendCanvasResourceNotifications(type);
|
|
154
161
|
});
|
|
@@ -184,6 +191,19 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
|
|
|
184
191
|
};
|
|
185
192
|
}
|
|
186
193
|
|
|
194
|
+
function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
|
|
195
|
+
const nodesByType: Record<string, number> = {};
|
|
196
|
+
for (const node of layout.nodes) {
|
|
197
|
+
nodesByType[node.type] = (nodesByType[node.type] ?? 0) + 1;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
nodeCount: layout.nodes.length,
|
|
201
|
+
edgeCount: layout.edges.length,
|
|
202
|
+
nodesByType,
|
|
203
|
+
viewport: layout.viewport,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
187
207
|
export async function startMcpServer(): Promise<void> {
|
|
188
208
|
const server = new McpServer({
|
|
189
209
|
name: 'pmx-canvas',
|
|
@@ -241,6 +261,12 @@ export async function startMcpServer(): Promise<void> {
|
|
|
241
261
|
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
242
262
|
height: z.number().optional().describe('Height in pixels (default: 600)'),
|
|
243
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'),
|
|
244
270
|
},
|
|
245
271
|
async (input) => {
|
|
246
272
|
const c = await ensureCanvas();
|
|
@@ -283,11 +309,13 @@ export async function startMcpServer(): Promise<void> {
|
|
|
283
309
|
toolName: z.string().describe('Tool name on the external MCP server'),
|
|
284
310
|
serverName: z.string().optional().describe('Optional display name for the external MCP server'),
|
|
285
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.'),
|
|
286
313
|
title: z.string().optional().describe('Optional canvas node title override'),
|
|
287
314
|
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
288
315
|
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
289
316
|
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
290
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'),
|
|
291
319
|
transport: z.union([
|
|
292
320
|
z.object({
|
|
293
321
|
type: z.literal('stdio'),
|
|
@@ -311,11 +339,13 @@ export async function startMcpServer(): Promise<void> {
|
|
|
311
339
|
toolName: input.toolName,
|
|
312
340
|
...(typeof input.serverName === 'string' ? { serverName: input.serverName } : {}),
|
|
313
341
|
...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
|
|
342
|
+
...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
|
|
314
343
|
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
315
344
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
316
345
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
317
346
|
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
318
347
|
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
348
|
+
...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
|
|
319
349
|
});
|
|
320
350
|
return {
|
|
321
351
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
@@ -337,27 +367,37 @@ export async function startMcpServer(): Promise<void> {
|
|
|
337
367
|
z.string().describe('JSON array string of Excalidraw elements'),
|
|
338
368
|
z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
|
|
339
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.'),
|
|
340
371
|
title: z.string().optional().describe('Optional canvas node title override'),
|
|
341
372
|
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
342
373
|
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
343
374
|
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
344
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.'),
|
|
345
377
|
},
|
|
346
|
-
async (input) => {
|
|
378
|
+
async (input, extra) => {
|
|
347
379
|
const c = await ensureCanvas();
|
|
348
380
|
try {
|
|
349
381
|
const result = await c.addDiagram({
|
|
350
382
|
elements: input.elements,
|
|
383
|
+
...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
|
|
351
384
|
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
352
385
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
353
386
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
354
387
|
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
355
388
|
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
389
|
+
...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
|
|
356
390
|
});
|
|
357
391
|
return {
|
|
358
392
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
359
393
|
};
|
|
360
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
|
+
}
|
|
361
401
|
return {
|
|
362
402
|
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
363
403
|
isError: true,
|
|
@@ -463,7 +503,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
463
503
|
// ── canvas_build_web_artifact ───────────────────────────────
|
|
464
504
|
server.tool(
|
|
465
505
|
'canvas_build_web_artifact',
|
|
466
|
-
'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. MCP callers pass source content in appTsx (the CLI app-file flag reads a file before calling this path). Builds
|
|
506
|
+
'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. MCP callers pass source content in appTsx (the CLI app-file flag reads a file before calling this path). Builds can exceed default 60s MCP client timeouts on cold workspaces; set a long client timeout or retry with the same projectPath/outputPath if the client times out. Optionally opens the generated artifact as an embedded node on the canvas. Read canvas://skills/web-artifacts-builder for the full workflow, stack, and anti-slop design guidelines before calling.',
|
|
467
507
|
{
|
|
468
508
|
title: z.string().describe('Artifact title used for default project and output paths'),
|
|
469
509
|
appTsx: z.string().describe('Contents for src/App.tsx'),
|
|
@@ -514,6 +554,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
514
554
|
bytes: result.fileSize,
|
|
515
555
|
projectPath: result.projectPath,
|
|
516
556
|
openedInCanvas: result.openedInCanvas,
|
|
557
|
+
completedAt: result.completedAt,
|
|
517
558
|
// `id` only present when a canvas node was actually created.
|
|
518
559
|
// See the matching block in src/server/server.ts handleCanvasBuildWebArtifact.
|
|
519
560
|
...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
|
|
@@ -1453,7 +1494,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1453
1494
|
|
|
1454
1495
|
server.tool(
|
|
1455
1496
|
'canvas_batch',
|
|
1456
|
-
'Run a batch of canvas operations with optional assigned references. Supports node.add, node.update, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
|
|
1497
|
+
'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
|
|
1457
1498
|
{
|
|
1458
1499
|
operations: z.array(z.object({
|
|
1459
1500
|
op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
|
|
@@ -1568,8 +1609,9 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1568
1609
|
if (!result.ok) {
|
|
1569
1610
|
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }] };
|
|
1570
1611
|
}
|
|
1612
|
+
const layout = await c.getLayout();
|
|
1571
1613
|
return {
|
|
1572
|
-
content: [{ type: 'text', text: JSON.stringify({ ok: true,
|
|
1614
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, restored: input.id, summary: buildSnapshotRestoreSummary(layout) }, null, 2) }],
|
|
1573
1615
|
};
|
|
1574
1616
|
},
|
|
1575
1617
|
);
|
|
@@ -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(() => {
|
|
@@ -1373,6 +1408,7 @@ function resolveBatchRefs(value: unknown, refs: Record<string, unknown>): unknow
|
|
|
1373
1408
|
if (typeof value === 'string' && value.startsWith('$')) {
|
|
1374
1409
|
const path = value.slice(1).split('.');
|
|
1375
1410
|
let current: unknown = refs[path[0] ?? ''];
|
|
1411
|
+
if (path.length === 1 && isPlainRecord(current) && typeof current.id === 'string') return current.id;
|
|
1376
1412
|
for (const segment of path.slice(1)) {
|
|
1377
1413
|
if (!isPlainRecord(current) && !Array.isArray(current)) return undefined;
|
|
1378
1414
|
current = (current as Record<string, unknown>)[segment];
|
|
@@ -140,11 +140,18 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
140
140
|
fields: [
|
|
141
141
|
{ name: 'title', type: 'string', required: false, description: 'Optional title.' },
|
|
142
142
|
{ name: 'content', type: 'string', required: false, description: 'Trace summary.' },
|
|
143
|
+
{ name: 'toolName', type: 'string', required: false, description: 'Tool or operation label shown in the trace pill; defaults to title.' },
|
|
144
|
+
{ name: 'category', type: 'string', required: false, description: 'Trace category color key: mcp, file, subagent, or other.' },
|
|
145
|
+
{ name: 'status', type: 'string', required: false, description: 'Trace status: running, success, or failed.' },
|
|
146
|
+
{ name: 'duration', type: 'string', required: false, description: 'Optional duration badge text.' },
|
|
147
|
+
{ name: 'resultSummary', type: 'string', required: false, description: 'Short trace result summary; defaults to content.' },
|
|
148
|
+
{ name: 'error', type: 'string', required: false, description: 'Short error message shown in failed traces.' },
|
|
143
149
|
],
|
|
144
150
|
example: {
|
|
145
151
|
type: 'trace',
|
|
146
152
|
title: 'Execution Trace',
|
|
147
153
|
content: 'Canvas actions and tool events.',
|
|
154
|
+
status: 'success',
|
|
148
155
|
},
|
|
149
156
|
},
|
|
150
157
|
{
|
|
@@ -378,12 +385,16 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
378
385
|
{ name: 'openInCanvas', type: 'boolean', required: false, description: 'Open the built artifact on the canvas (default true).' },
|
|
379
386
|
{ name: 'includeLogs', type: 'boolean', required: false, description: 'Include raw build stdout/stderr in the response (default false).' },
|
|
380
387
|
{ name: 'deps', type: 'string[]', required: false, description: 'Optional npm dependencies to add before bundling, e.g. recharts.', aliases: ['deps'] },
|
|
388
|
+
{ name: 'timeoutMs', type: 'number', required: false, description: 'Build command timeout in milliseconds. This controls subprocess timeout, not the MCP client request timeout.' },
|
|
381
389
|
],
|
|
382
390
|
example: {
|
|
383
391
|
title: 'Dashboard Artifact',
|
|
384
392
|
appTsx: 'export default function App() { return <main>Artifact</main>; }',
|
|
385
393
|
indexCss: 'body { background: #123456; color: white; }',
|
|
386
394
|
},
|
|
395
|
+
notes: [
|
|
396
|
+
'Cold builds can exceed default 60s MCP client timeouts; configure a longer MCP call timeout or retry with the same projectPath/outputPath if the first call times out.',
|
|
397
|
+
],
|
|
387
398
|
},
|
|
388
399
|
];
|
|
389
400
|
|
|
@@ -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,61 +153,38 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
|
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
let changed = false;
|
|
137
|
-
const
|
|
138
|
-
const labelByContainer = new Map<string, Record<string, unknown>>();
|
|
139
|
-
const textIdsConvertedToLabels = new Set<string>();
|
|
156
|
+
const labelsByContainer = new Map<string, Record<string, unknown>>();
|
|
140
157
|
|
|
141
158
|
for (const element of elements) {
|
|
142
159
|
if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
|
|
143
160
|
const container = elementsById.get(element.containerId);
|
|
144
|
-
if (!container) continue;
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const text = typeof element.text === 'string' ? element.text.trim() : '';
|
|
149
|
-
if (!isRecord(container.label) && text.length > 0) {
|
|
150
|
-
labelByContainer.set(element.containerId, {
|
|
151
|
-
text,
|
|
152
|
-
...(typeof element.fontSize === 'number' && Number.isFinite(element.fontSize) ? { fontSize: element.fontSize } : {}),
|
|
153
|
-
});
|
|
154
|
-
textIdsConvertedToLabels.add(element.id);
|
|
155
|
-
}
|
|
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);
|
|
156
165
|
}
|
|
157
166
|
|
|
158
|
-
const normalized
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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;
|
|
162
179
|
}
|
|
163
|
-
if (typeof element.id !== 'string') return element;
|
|
164
|
-
const boundTextIds = boundElementIdsByContainer.get(element.id);
|
|
165
|
-
const label = labelByContainer.get(element.id);
|
|
166
|
-
if ((!boundTextIds || boundTextIds.size === 0) && !label) return element;
|
|
167
|
-
|
|
168
|
-
const existing = Array.isArray(element.boundElements)
|
|
169
|
-
? element.boundElements.filter(isRecord)
|
|
170
|
-
: [];
|
|
171
|
-
const remainingExisting = existing.filter((boundElement) => {
|
|
172
|
-
return !(boundElement.type === 'text' && typeof boundElement.id === 'string' && textIdsConvertedToLabels.has(boundElement.id));
|
|
173
|
-
});
|
|
174
|
-
const existingTextIds = new Set(
|
|
175
|
-
remainingExisting
|
|
176
|
-
.filter((boundElement) => boundElement.type === 'text' && typeof boundElement.id === 'string')
|
|
177
|
-
.map((boundElement) => boundElement.id as string),
|
|
178
|
-
);
|
|
179
|
-
const missing = [...(boundTextIds ?? [])]
|
|
180
|
-
.filter((id) => !textIdsConvertedToLabels.has(id) && !existingTextIds.has(id));
|
|
181
|
-
if (missing.length === 0 && !label && remainingExisting.length === existing.length) return element;
|
|
182
180
|
|
|
183
181
|
changed = true;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
...
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
};
|
|
191
|
-
});
|
|
182
|
+
const { boundElements: _boundElements, ...container } = element;
|
|
183
|
+
normalized.push({
|
|
184
|
+
...container,
|
|
185
|
+
label: labelsByContainer.get(element.id),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
192
188
|
|
|
193
189
|
return changed ? normalized : elements;
|
|
194
190
|
}
|
|
@@ -355,10 +351,12 @@ export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): E
|
|
|
355
351
|
serverName: EXCALIDRAW_SERVER_NAME,
|
|
356
352
|
toolArguments: { elements },
|
|
357
353
|
};
|
|
354
|
+
if (typeof input.nodeId === 'string' && input.nodeId.trim().length > 0) out.nodeId = input.nodeId.trim();
|
|
358
355
|
if (typeof input.title === 'string' && input.title.trim().length > 0) out.title = input.title.trim();
|
|
359
356
|
if (typeof input.x === 'number' && Number.isFinite(input.x)) out.x = input.x;
|
|
360
357
|
if (typeof input.y === 'number' && Number.isFinite(input.y)) out.y = input.y;
|
|
361
358
|
if (typeof input.width === 'number' && Number.isFinite(input.width)) out.width = input.width;
|
|
362
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;
|
|
363
361
|
return out;
|
|
364
362
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
ungroupCanvasNodes,
|
|
34
34
|
validateCanvasNodePatch,
|
|
35
35
|
hasStructuredNodeUpdateFields,
|
|
36
|
+
hasTraceNodeDataFields,
|
|
37
|
+
mergeTraceNodeDataFields,
|
|
36
38
|
} from './canvas-operations.js';
|
|
37
39
|
import { validateCanvasLayout } from './canvas-validation.js';
|
|
38
40
|
import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
|
|
@@ -158,6 +160,12 @@ export class PmxCanvas extends EventEmitter {
|
|
|
158
160
|
type: CanvasNodeState['type'];
|
|
159
161
|
title?: string;
|
|
160
162
|
content?: string;
|
|
163
|
+
toolName?: string;
|
|
164
|
+
category?: string;
|
|
165
|
+
status?: string;
|
|
166
|
+
duration?: string;
|
|
167
|
+
resultSummary?: string;
|
|
168
|
+
error?: string;
|
|
161
169
|
x?: number;
|
|
162
170
|
y?: number;
|
|
163
171
|
width?: number;
|
|
@@ -243,9 +251,10 @@ export class PmxCanvas extends EventEmitter {
|
|
|
243
251
|
patch.title !== undefined ||
|
|
244
252
|
patch.content !== undefined ||
|
|
245
253
|
typeof patch.arrangeLocked === 'boolean' ||
|
|
246
|
-
typeof patch.strictSize === 'boolean'
|
|
254
|
+
typeof patch.strictSize === 'boolean' ||
|
|
255
|
+
(existing.type === 'trace' && hasTraceNodeDataFields(patch))
|
|
247
256
|
) {
|
|
248
|
-
|
|
257
|
+
const nextData = {
|
|
249
258
|
...existing.data,
|
|
250
259
|
...(patch.data && typeof patch.data === 'object' && !Array.isArray(patch.data) ? patch.data : {}),
|
|
251
260
|
...(typeof patch.title === 'string' ? { title: patch.title } : {}),
|
|
@@ -253,6 +262,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
253
262
|
...(typeof patch.arrangeLocked === 'boolean' ? { arrangeLocked: patch.arrangeLocked } : {}),
|
|
254
263
|
...(typeof patch.strictSize === 'boolean' ? { strictSize: patch.strictSize } : {}),
|
|
255
264
|
};
|
|
265
|
+
resolvedPatch.data = existing.type === 'trace'
|
|
266
|
+
? mergeTraceNodeDataFields(nextData, patch)
|
|
267
|
+
: nextData;
|
|
256
268
|
}
|
|
257
269
|
|
|
258
270
|
const error = validateCanvasNodePatch({
|
|
@@ -520,21 +532,36 @@ export class PmxCanvas extends EventEmitter {
|
|
|
520
532
|
transport: ExternalMcpTransportConfig;
|
|
521
533
|
toolName: string;
|
|
522
534
|
toolArguments?: Record<string, unknown>;
|
|
535
|
+
nodeId?: string;
|
|
523
536
|
serverName?: string;
|
|
524
537
|
title?: string;
|
|
525
538
|
x?: number;
|
|
526
539
|
y?: number;
|
|
527
540
|
width?: number;
|
|
528
541
|
height?: number;
|
|
542
|
+
timeoutMs?: number;
|
|
529
543
|
}): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
|
|
544
|
+
const targetNode = input.nodeId ? canvasState.getNode(input.nodeId) : undefined;
|
|
545
|
+
if (input.nodeId && !targetNode) {
|
|
546
|
+
throw new Error(`Node "${input.nodeId}" not found.`);
|
|
547
|
+
}
|
|
548
|
+
if (targetNode && (targetNode.type !== 'mcp-app' || targetNode.data.mode !== 'ext-app')) {
|
|
549
|
+
throw new Error(`Node "${input.nodeId}" is not an external app node.`);
|
|
550
|
+
}
|
|
551
|
+
|
|
530
552
|
const opened = await openExternalMcpApp({
|
|
531
553
|
transport: input.transport,
|
|
532
554
|
toolName: input.toolName,
|
|
533
555
|
...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
|
|
534
556
|
...(input.serverName ? { serverName: input.serverName } : {}),
|
|
557
|
+
...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
|
|
535
558
|
});
|
|
536
559
|
const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
537
|
-
const
|
|
560
|
+
const previousSessionId = targetNode?.data.appSessionId;
|
|
561
|
+
if (typeof previousSessionId === 'string' && previousSessionId.trim().length > 0) {
|
|
562
|
+
closeMcpAppSession(previousSessionId);
|
|
563
|
+
}
|
|
564
|
+
const nodeIdSeed = input.nodeId ?? `ext-app-${toolCallId}`;
|
|
538
565
|
const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
|
|
539
566
|
? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
|
|
540
567
|
: opened.toolResult;
|
|
@@ -566,7 +593,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
566
593
|
success: toolResult.isError !== true,
|
|
567
594
|
result: toolResult,
|
|
568
595
|
});
|
|
569
|
-
const nodeId = this.findCanvasExtAppNodeId(toolCallId);
|
|
596
|
+
const nodeId = input.nodeId ?? this.findCanvasExtAppNodeId(toolCallId);
|
|
570
597
|
return {
|
|
571
598
|
ok: true,
|
|
572
599
|
...(nodeId ? { id: nodeId } : {}),
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
TextResourceContents,
|
|
13
13
|
Tool,
|
|
14
14
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
15
16
|
import {
|
|
16
17
|
EXTENSION_ID,
|
|
17
18
|
RESOURCE_MIME_TYPE,
|
|
@@ -47,6 +48,7 @@ export interface OpenMcpAppInput {
|
|
|
47
48
|
toolName: string;
|
|
48
49
|
toolArguments?: Record<string, unknown>;
|
|
49
50
|
serverName?: string;
|
|
51
|
+
timeoutMs?: number;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export interface OpenMcpAppResult {
|
|
@@ -184,6 +186,12 @@ function normalizeServerName(raw: string | undefined, transport: ExternalMcpTran
|
|
|
184
186
|
return trimmed.length > 0 ? trimmed : defaultServerName(transport);
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
function requestOptions(timeoutMs: number | undefined): RequestOptions | undefined {
|
|
190
|
+
return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
191
|
+
? { timeout: timeoutMs }
|
|
192
|
+
: undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
187
195
|
function buildTransport(config: ExternalMcpTransportConfig): RuntimeTransport {
|
|
188
196
|
if (config.type === 'http') {
|
|
189
197
|
return new StreamableHTTPClientTransport(new URL(config.url), {
|
|
@@ -209,15 +217,16 @@ function buildTransport(config: ExternalMcpTransportConfig): RuntimeTransport {
|
|
|
209
217
|
async function createSession(
|
|
210
218
|
transportConfig: ExternalMcpTransportConfig,
|
|
211
219
|
serverName?: string,
|
|
220
|
+
timeoutMs?: number,
|
|
212
221
|
): Promise<McpAppSession> {
|
|
213
222
|
const transport = buildTransport(transportConfig);
|
|
214
223
|
const client = new Client(
|
|
215
224
|
{ name: 'pmx-canvas-app-host', version: '0.1.0' },
|
|
216
225
|
{ capabilities: clientCapabilities },
|
|
217
226
|
);
|
|
218
|
-
await client.connect(transport);
|
|
227
|
+
await client.connect(transport, requestOptions(timeoutMs));
|
|
219
228
|
|
|
220
|
-
const toolList = await client.listTools();
|
|
229
|
+
const toolList = await client.listTools(undefined, requestOptions(timeoutMs));
|
|
221
230
|
const session: McpAppSession = {
|
|
222
231
|
id: randomId('mcp-app-session'),
|
|
223
232
|
serverName: normalizeServerName(serverName, transportConfig),
|
|
@@ -350,7 +359,8 @@ function prepareResourceHtml(html: string, meta: McpUiResourceMeta | undefined):
|
|
|
350
359
|
}
|
|
351
360
|
|
|
352
361
|
export async function openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult> {
|
|
353
|
-
const
|
|
362
|
+
const options = requestOptions(input.timeoutMs);
|
|
363
|
+
const session = await createSession(input.transport, input.serverName, input.timeoutMs);
|
|
354
364
|
try {
|
|
355
365
|
const tool = await findTool(session, input.toolName);
|
|
356
366
|
const resourceUri = getToolUiResourceUri(tool);
|
|
@@ -362,9 +372,9 @@ export async function openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResu
|
|
|
362
372
|
const rawToolResult = await session.client.callTool({
|
|
363
373
|
name: tool.name,
|
|
364
374
|
arguments: toolInput,
|
|
365
|
-
});
|
|
375
|
+
}, undefined, options);
|
|
366
376
|
const toolResult = normalizeExtAppToolResult({ result: rawToolResult });
|
|
367
|
-
const readResult = await session.client.readResource({ uri: resourceUri });
|
|
377
|
+
const readResult = await session.client.readResource({ uri: resourceUri }, options);
|
|
368
378
|
const resourceMeta = resourceMetaFromReadResult(readResult);
|
|
369
379
|
const html = prepareResourceHtml(htmlContentFromReadResult(readResult, resourceUri), resourceMeta);
|
|
370
380
|
|