pmx-canvas 0.1.3 → 0.1.4

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.
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
3
3
  import type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
4
+ import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
4
5
  import { onFileNodeChanged } from './file-watcher.js';
5
6
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
6
7
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
@@ -440,18 +441,10 @@ export class PmxCanvas extends EventEmitter {
440
441
  }
441
442
 
442
443
  private findCanvasExtAppNodeId(toolCallId: string): string | null {
443
- const directId = `ext-app-${toolCallId}`;
444
- if (canvasState.getNode(directId)) return directId;
445
- for (const node of canvasState.getLayout().nodes) {
446
- if (
447
- node.type === 'mcp-app' &&
448
- node.data.mode === 'ext-app' &&
449
- node.data.toolCallId === toolCallId
450
- ) {
451
- return node.id;
452
- }
453
- }
454
- return null;
444
+ return findCanvasExtAppNodeId(toolCallId, {
445
+ getNode: (id) => canvasState.getNode(id),
446
+ listNodes: () => canvasState.getLayout().nodes,
447
+ });
455
448
  }
456
449
 
457
450
  describeSchema() {
@@ -488,20 +481,21 @@ export class PmxCanvas extends EventEmitter {
488
481
  y?: number;
489
482
  width?: number;
490
483
  height?: number;
491
- }): Promise<{ ok: true; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
484
+ }): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
492
485
  const opened = await openExternalMcpApp({
493
486
  transport: input.transport,
494
487
  toolName: input.toolName,
495
488
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
496
489
  ...(input.serverName ? { serverName: input.serverName } : {}),
497
490
  });
498
- const toolCallId = `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
491
+ const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
499
492
  const nodeIdSeed = `ext-app-${toolCallId}`;
500
493
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
501
494
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
502
495
  : opened.toolResult;
503
496
  emitPrimaryWorkbenchEvent('ext-app-open', {
504
497
  toolCallId,
498
+ nodeId: nodeIdSeed,
505
499
  title: input.title ?? opened.tool.title ?? opened.tool.name,
506
500
  html: opened.html,
507
501
  toolInput: opened.toolInput,
@@ -521,6 +515,7 @@ export class PmxCanvas extends EventEmitter {
521
515
  });
522
516
  emitPrimaryWorkbenchEvent('ext-app-result', {
523
517
  toolCallId,
518
+ nodeId: nodeIdSeed,
524
519
  serverName: opened.serverName,
525
520
  toolName: opened.toolName,
526
521
  success: toolResult.isError !== true,
@@ -529,6 +524,7 @@ export class PmxCanvas extends EventEmitter {
529
524
  const nodeId = this.findCanvasExtAppNodeId(toolCallId);
530
525
  return {
531
526
  ok: true,
527
+ ...(nodeId ? { id: nodeId } : {}),
532
528
  nodeId,
533
529
  toolCallId,
534
530
  sessionId: opened.sessionId,
@@ -538,7 +534,7 @@ export class PmxCanvas extends EventEmitter {
538
534
 
539
535
  async addDiagram(
540
536
  input: DiagramPresetOpenInput,
541
- ): Promise<{ ok: true; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
537
+ ): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
542
538
  const built = buildExcalidrawOpenMcpAppInput(input);
543
539
  return this.openMcpApp(built);
544
540
  }
@@ -46,6 +46,7 @@ import type {
46
46
  ListToolsResult,
47
47
  } from '@modelcontextprotocol/sdk/types.js';
48
48
  import { type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
49
+ import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
49
50
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
50
51
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
51
52
  import {
@@ -602,31 +603,42 @@ function getMarkdownPlacement(): { x: number; y: number } {
602
603
  }
603
604
 
604
605
  function findCanvasExtAppNodeId(toolCallId: string): string | null {
605
- const directId = `ext-app-${toolCallId}`;
606
- if (canvasState.getNode(directId)) return directId;
607
- for (const node of canvasState.getLayout().nodes) {
608
- if (
609
- node.type === 'mcp-app' &&
610
- node.data.mode === 'ext-app' &&
611
- node.data.toolCallId === toolCallId
612
- ) {
613
- return node.id;
614
- }
615
- }
616
- return null;
606
+ return findCanvasExtAppNodeIdShared(toolCallId, {
607
+ getNode: (id) => canvasState.getNode(id),
608
+ listNodes: () => canvasState.getLayout().nodes,
609
+ });
617
610
  }
618
611
 
619
612
  function isCheckpointToolName(toolName: string): boolean {
620
613
  return toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL || toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL;
621
614
  }
622
615
 
616
+ /**
617
+ * Decide whether a fresh `callServerTool` result should *replace* the
618
+ * canvas node's bootstrap-replay `toolResult`.
619
+ *
620
+ * The bootstrap-replay toolResult is what the server re-sends to the
621
+ * widget on reload to restore visible state. We only want to overwrite
622
+ * it when the new result genuinely carries widget state — `isError` or
623
+ * `structuredContent`. A plain-text result (e.g. `read_checkpoint`
624
+ * returning a string status, or any informational message) updates
625
+ * `appModelContext` for the agent's record but should *not* clobber the
626
+ * bootstrap entry, because doing so would replace the widget's restored
627
+ * state with conversational noise on the next reload.
628
+ *
629
+ * This separation is exercised by:
630
+ * - tests/unit/server-api.test.ts "keeps ext-app model context
631
+ * separate from the replayed tool result" (text-only result preserves
632
+ * bootstrap replay)
633
+ * - tests/unit/server-api.test.ts "app-only text tool results update
634
+ * model context without replacing bootstrap replay"
635
+ * - tests/unit/server-api.test.ts "rehydrates Excalidraw checkpoint
636
+ * replay after server restart" (structured-content result becomes
637
+ * the new bootstrap replay)
638
+ */
623
639
  function shouldReplayAppToolResult(toolName: string, result: CallToolResult): boolean {
624
640
  void toolName;
625
- return Boolean(
626
- result.isError ||
627
- result.structuredContent ||
628
- result.content.some((entry) => entry.type !== 'text' || entry.text !== 'ok'),
629
- );
641
+ return Boolean(result.isError || result.structuredContent);
630
642
  }
631
643
 
632
644
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1545,10 +1557,22 @@ async function handleCanvasValidateSpec(req: Request): Promise<Response> {
1545
1557
  data,
1546
1558
  ...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
1547
1559
  ...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
1560
+ ...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
1548
1561
  ...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
1549
1562
  ...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
1563
+ ...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
1564
+ ...(Array.isArray(body.metrics)
1565
+ ? { metrics: body.metrics.filter((m: unknown): m is string => typeof m === 'string') }
1566
+ : {}),
1567
+ ...(Array.isArray(body.series)
1568
+ ? { series: body.series.filter((s: unknown): s is string => typeof s === 'string') }
1569
+ : {}),
1570
+ ...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
1571
+ ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
1550
1572
  ...(aggregate ? { aggregate } : {}),
1551
1573
  ...(typeof body.color === 'string' ? { color: body.color } : {}),
1574
+ ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
1575
+ ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
1552
1576
  ...(typeof body.height === 'number' ? { height: body.height } : {}),
1553
1577
  },
1554
1578
  }));
@@ -1871,7 +1895,7 @@ function handleRead(pathLike: string): Response {
1871
1895
  }
1872
1896
 
1873
1897
  function randomExtAppToolCallId(): string {
1874
- return `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1898
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1875
1899
  }
1876
1900
 
1877
1901
  function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
@@ -1951,7 +1975,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1951
1975
  });
1952
1976
 
1953
1977
  const toolCallId = randomExtAppToolCallId();
1954
- const nodeIdSeed = `ext-app-${toolCallId}`;
1978
+ const nodeIdSeed = toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
1955
1979
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
1956
1980
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
1957
1981
  : opened.toolResult;
@@ -1959,6 +1983,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1959
1983
 
1960
1984
  emitPrimaryWorkbenchEvent('ext-app-open', {
1961
1985
  toolCallId,
1986
+ nodeId: nodeIdSeed,
1962
1987
  title: nodeTitle,
1963
1988
  html: opened.html,
1964
1989
  toolInput: opened.toolInput,
@@ -1978,6 +2003,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1978
2003
  });
1979
2004
  emitPrimaryWorkbenchEvent('ext-app-result', {
1980
2005
  toolCallId,
2006
+ nodeId: nodeIdSeed,
1981
2007
  serverName: opened.serverName,
1982
2008
  toolName: opened.toolName,
1983
2009
  success: toolResult.isError !== true,
@@ -1987,6 +2013,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1987
2013
 
1988
2014
  return responseJson({
1989
2015
  ok: true,
2016
+ ...(nodeId ? { id: nodeId } : {}),
1990
2017
  nodeId,
1991
2018
  toolCallId,
1992
2019
  sessionId: opened.sessionId,
@@ -3151,10 +3178,13 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3151
3178
  } else if (event === 'ext-app-open') {
3152
3179
  const toolCallId = payload.toolCallId as string;
3153
3180
  if (!toolCallId) return;
3154
- const id = `ext-app-${toolCallId}`;
3181
+ const id = typeof payload.nodeId === 'string' && payload.nodeId.length > 0
3182
+ ? payload.nodeId
3183
+ : toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
3155
3184
  const dataPatch = {
3156
3185
  mode: 'ext-app',
3157
3186
  toolCallId,
3187
+ nodeId: id,
3158
3188
  ...(typeof payload.title === 'string' && payload.title.trim().length > 0
3159
3189
  ? { title: payload.title.trim() }
3160
3190
  : {}),
@@ -3222,7 +3252,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3222
3252
  } else if (event === 'ext-app-update') {
3223
3253
  const toolCallId = payload.toolCallId as string;
3224
3254
  if (!toolCallId) return;
3255
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3225
3256
  const id =
3257
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3226
3258
  findCanvasExtAppNodeId(toolCallId) ||
3227
3259
  (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3228
3260
  ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
@@ -3235,7 +3267,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3235
3267
  } else if (event === 'ext-app-result') {
3236
3268
  const toolCallId = payload.toolCallId as string;
3237
3269
  if (!toolCallId) return;
3270
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3238
3271
  const id =
3272
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3239
3273
  findCanvasExtAppNodeId(toolCallId) ||
3240
3274
  (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3241
3275
  ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
@@ -494,6 +494,7 @@ export function openWebArtifactInCanvas(input: {
494
494
  trustedDomain: true,
495
495
  sourceServer: 'pmx-canvas',
496
496
  hostMode: 'hosted',
497
+ viewerType: 'web-artifact',
497
498
  },
498
499
  };
499
500