pmx-canvas 0.1.0

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.
Files changed (226) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +865 -0
  4. package/dist/canvas/global.css +3173 -0
  5. package/dist/canvas/index.js +183 -0
  6. package/dist/json-render/index.css +2 -0
  7. package/dist/json-render/index.js +389 -0
  8. package/dist/types/cli/agent.d.ts +13 -0
  9. package/dist/types/cli/index.d.ts +2 -0
  10. package/dist/types/cli/watch.d.ts +5 -0
  11. package/dist/types/client/App.d.ts +1 -0
  12. package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
  13. package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
  14. package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
  15. package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
  16. package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
  18. package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
  19. package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
  20. package/dist/types/client/canvas/DockedNode.d.ts +4 -0
  21. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
  23. package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
  24. package/dist/types/client/canvas/Minimap.d.ts +23 -0
  25. package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
  26. package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
  27. package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
  28. package/dist/types/client/canvas/snap-guides.d.ts +23 -0
  29. package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
  30. package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
  31. package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
  32. package/dist/types/client/ext-app/bridge.d.ts +161 -0
  33. package/dist/types/client/icons.d.ts +70 -0
  34. package/dist/types/client/index.d.ts +1 -0
  35. package/dist/types/client/nodes/ContextNode.d.ts +34 -0
  36. package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
  37. package/dist/types/client/nodes/FileNode.d.ts +5 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -0
  39. package/dist/types/client/nodes/ImageNode.d.ts +10 -0
  40. package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
  41. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
  42. package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
  43. package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
  44. package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
  45. package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
  46. package/dist/types/client/nodes/PromptNode.d.ts +5 -0
  47. package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
  48. package/dist/types/client/nodes/StatusNode.d.ts +4 -0
  49. package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
  50. package/dist/types/client/nodes/TraceNode.d.ts +4 -0
  51. package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
  52. package/dist/types/client/nodes/image-warnings.d.ts +6 -0
  53. package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
  54. package/dist/types/client/nodes/md-format.d.ts +25 -0
  55. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  56. package/dist/types/client/state/attention-store.d.ts +25 -0
  57. package/dist/types/client/state/canvas-store.d.ts +74 -0
  58. package/dist/types/client/state/intent-bridge.d.ts +158 -0
  59. package/dist/types/client/state/sse-bridge.d.ts +5 -0
  60. package/dist/types/client/theme/tokens.d.ts +27 -0
  61. package/dist/types/client/types.d.ts +40 -0
  62. package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
  63. package/dist/types/client/utils/placement.d.ts +1 -0
  64. package/dist/types/client/utils/platform.d.ts +2 -0
  65. package/dist/types/json-render/catalog.d.ts +815 -0
  66. package/dist/types/json-render/charts/components.d.ts +54 -0
  67. package/dist/types/json-render/charts/definitions.d.ts +103 -0
  68. package/dist/types/json-render/charts/extra-components.d.ts +58 -0
  69. package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
  70. package/dist/types/json-render/renderer/index.d.ts +16 -0
  71. package/dist/types/json-render/schema.d.ts +46 -0
  72. package/dist/types/json-render/server.d.ts +55 -0
  73. package/dist/types/mcp/server.d.ts +22 -0
  74. package/dist/types/server/agent-context.d.ts +21 -0
  75. package/dist/types/server/artifact-paths.d.ts +3 -0
  76. package/dist/types/server/canvas-operations.d.ts +154 -0
  77. package/dist/types/server/canvas-provenance.d.ts +13 -0
  78. package/dist/types/server/canvas-schema.d.ts +49 -0
  79. package/dist/types/server/canvas-serialization.d.ts +25 -0
  80. package/dist/types/server/canvas-state.d.ts +174 -0
  81. package/dist/types/server/canvas-validation.d.ts +33 -0
  82. package/dist/types/server/chart-template.d.ts +29 -0
  83. package/dist/types/server/code-graph.d.ts +67 -0
  84. package/dist/types/server/context-cards.d.ts +24 -0
  85. package/dist/types/server/diagram-presets.d.ts +28 -0
  86. package/dist/types/server/ext-app-call-registry.d.ts +16 -0
  87. package/dist/types/server/ext-app-tool-result.d.ts +1 -0
  88. package/dist/types/server/file-watcher.d.ts +16 -0
  89. package/dist/types/server/index.d.ts +243 -0
  90. package/dist/types/server/mcp-app-candidate.d.ts +25 -0
  91. package/dist/types/server/mcp-app-host.d.ts +65 -0
  92. package/dist/types/server/mcp-app-runtime.d.ts +47 -0
  93. package/dist/types/server/mutation-history.d.ts +105 -0
  94. package/dist/types/server/placement.d.ts +37 -0
  95. package/dist/types/server/server.d.ts +103 -0
  96. package/dist/types/server/spatial-analysis.d.ts +87 -0
  97. package/dist/types/server/trace-manager.d.ts +48 -0
  98. package/dist/types/server/web-artifacts.d.ts +50 -0
  99. package/dist/types/server/webpage-node.d.ts +25 -0
  100. package/dist/types/shared/auto-arrange.d.ts +29 -0
  101. package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
  102. package/dist/types/shared/placement.d.ts +26 -0
  103. package/dist/types/shared/semantic-attention.d.ts +97 -0
  104. package/package.json +109 -0
  105. package/skills/data-analysis/SKILL.md +324 -0
  106. package/skills/doc-coauthoring/SKILL.md +375 -0
  107. package/skills/frontend-design/SKILL.md +45 -0
  108. package/skills/json-render-codegen/SKILL.md +112 -0
  109. package/skills/json-render-core/SKILL.md +265 -0
  110. package/skills/json-render-ink/SKILL.md +273 -0
  111. package/skills/json-render-mcp/SKILL.md +132 -0
  112. package/skills/json-render-react/SKILL.md +264 -0
  113. package/skills/json-render-shadcn/SKILL.md +159 -0
  114. package/skills/playwright-cli/SKILL.md +67 -0
  115. package/skills/pmx-canvas/SKILL.md +668 -0
  116. package/skills/pmx-canvas/evals/evals.json +186 -0
  117. package/skills/pmx-canvas-testing/SKILL.md +78 -0
  118. package/skills/published-consumer-e2e/SKILL.md +43 -0
  119. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
  120. package/skills/web-artifacts-builder/SKILL.md +80 -0
  121. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
  122. package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
  123. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  124. package/skills/web-design-guidelines/SKILL.md +39 -0
  125. package/src/cli/agent.ts +2144 -0
  126. package/src/cli/index.ts +622 -0
  127. package/src/cli/watch.ts +88 -0
  128. package/src/client/App.tsx +507 -0
  129. package/src/client/canvas/AttentionHistory.tsx +81 -0
  130. package/src/client/canvas/AttentionToast.tsx +19 -0
  131. package/src/client/canvas/CanvasNode.tsx +363 -0
  132. package/src/client/canvas/CanvasViewport.tsx +590 -0
  133. package/src/client/canvas/CommandPalette.tsx +302 -0
  134. package/src/client/canvas/ContextMenu.tsx +601 -0
  135. package/src/client/canvas/ContextPinBar.tsx +25 -0
  136. package/src/client/canvas/ContextPinHud.tsx +22 -0
  137. package/src/client/canvas/DockedNode.tsx +66 -0
  138. package/src/client/canvas/EdgeLayer.tsx +280 -0
  139. package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
  140. package/src/client/canvas/FocusFieldLayer.tsx +107 -0
  141. package/src/client/canvas/Minimap.tsx +301 -0
  142. package/src/client/canvas/SelectionBar.tsx +69 -0
  143. package/src/client/canvas/ShortcutOverlay.tsx +69 -0
  144. package/src/client/canvas/SnapshotPanel.tsx +236 -0
  145. package/src/client/canvas/snap-guides.ts +170 -0
  146. package/src/client/canvas/use-node-drag.ts +51 -0
  147. package/src/client/canvas/use-node-resize.ts +59 -0
  148. package/src/client/canvas/use-pan-zoom.ts +191 -0
  149. package/src/client/ext-app/bridge.ts +542 -0
  150. package/src/client/icons.tsx +424 -0
  151. package/src/client/index.tsx +7 -0
  152. package/src/client/nodes/ContextNode.tsx +412 -0
  153. package/src/client/nodes/ExtAppFrame.tsx +509 -0
  154. package/src/client/nodes/FileNode.tsx +256 -0
  155. package/src/client/nodes/GroupNode.tsx +39 -0
  156. package/src/client/nodes/ImageNode.tsx +160 -0
  157. package/src/client/nodes/InlineFormatBar.tsx +169 -0
  158. package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
  159. package/src/client/nodes/LedgerNode.tsx +37 -0
  160. package/src/client/nodes/MarkdownNode.tsx +359 -0
  161. package/src/client/nodes/McpAppNode.tsx +85 -0
  162. package/src/client/nodes/MdFormatBar.tsx +109 -0
  163. package/src/client/nodes/PromptNode.tsx +597 -0
  164. package/src/client/nodes/ResponseNode.tsx +153 -0
  165. package/src/client/nodes/StatusNode.tsx +84 -0
  166. package/src/client/nodes/StatusSummary.tsx +38 -0
  167. package/src/client/nodes/TraceNode.tsx +120 -0
  168. package/src/client/nodes/WebpageNode.tsx +288 -0
  169. package/src/client/nodes/image-warnings.ts +95 -0
  170. package/src/client/nodes/inline-editor-commands.ts +37 -0
  171. package/src/client/nodes/md-format.ts +206 -0
  172. package/src/client/state/attention-bridge.ts +328 -0
  173. package/src/client/state/attention-store.ts +73 -0
  174. package/src/client/state/canvas-store.ts +631 -0
  175. package/src/client/state/intent-bridge.ts +315 -0
  176. package/src/client/state/sse-bridge.ts +965 -0
  177. package/src/client/theme/global.css +3173 -0
  178. package/src/client/theme/tokens.ts +72 -0
  179. package/src/client/types-shims.d.ts +5 -0
  180. package/src/client/types.ts +81 -0
  181. package/src/client/utils/ext-app-tool-result.ts +4 -0
  182. package/src/client/utils/placement.ts +4 -0
  183. package/src/client/utils/platform.ts +2 -0
  184. package/src/json-render/catalog.ts +256 -0
  185. package/src/json-render/charts/components.tsx +198 -0
  186. package/src/json-render/charts/definitions.ts +81 -0
  187. package/src/json-render/charts/extra-components.tsx +267 -0
  188. package/src/json-render/charts/extra-definitions.ts +145 -0
  189. package/src/json-render/renderer/index.css +174 -0
  190. package/src/json-render/renderer/index.tsx +86 -0
  191. package/src/json-render/schema.ts +62 -0
  192. package/src/json-render/server.ts +597 -0
  193. package/src/mcp/server.ts +1377 -0
  194. package/src/server/agent-context.ts +242 -0
  195. package/src/server/artifact-paths.ts +17 -0
  196. package/src/server/canvas-operations.ts +1279 -0
  197. package/src/server/canvas-provenance.ts +243 -0
  198. package/src/server/canvas-schema.ts +432 -0
  199. package/src/server/canvas-serialization.ts +95 -0
  200. package/src/server/canvas-state.ts +1134 -0
  201. package/src/server/canvas-validation.ts +114 -0
  202. package/src/server/chart-template.ts +449 -0
  203. package/src/server/code-graph.ts +370 -0
  204. package/src/server/context-cards.ts +31 -0
  205. package/src/server/diagram-presets.ts +71 -0
  206. package/src/server/ext-app-call-registry.ts +77 -0
  207. package/src/server/ext-app-tool-result.ts +4 -0
  208. package/src/server/file-watcher.ts +121 -0
  209. package/src/server/index.ts +647 -0
  210. package/src/server/mcp-app-candidate.ts +174 -0
  211. package/src/server/mcp-app-host.ts +814 -0
  212. package/src/server/mcp-app-runtime.ts +459 -0
  213. package/src/server/mutation-history.ts +350 -0
  214. package/src/server/placement.ts +125 -0
  215. package/src/server/server.ts +3846 -0
  216. package/src/server/spatial-analysis.ts +356 -0
  217. package/src/server/trace-manager.ts +333 -0
  218. package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
  219. package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
  220. package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
  221. package/src/server/web-artifacts.ts +442 -0
  222. package/src/server/webpage-node.ts +328 -0
  223. package/src/shared/auto-arrange.ts +439 -0
  224. package/src/shared/ext-app-tool-result.ts +76 -0
  225. package/src/shared/placement.ts +81 -0
  226. package/src/shared/semantic-attention.ts +598 -0
@@ -0,0 +1,814 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ export type McpAppHostCapabilityState = 'supported' | 'unsupported' | 'degraded';
6
+
7
+ export interface McpAppHostCapability {
8
+ serverName: string;
9
+ state: McpAppHostCapabilityState;
10
+ reasonCode: string;
11
+ runtimeReady: boolean;
12
+ serverSupportsHost: boolean;
13
+ updatedAt: string;
14
+ }
15
+
16
+ export type McpAppHostSessionState = 'active' | 'background' | 'closed';
17
+
18
+ export interface McpAppHostSession {
19
+ sessionId: string;
20
+ sourceServer: string | null;
21
+ sourceTool: string;
22
+ url: string;
23
+ inferredType: string;
24
+ trustedDomain: boolean;
25
+ state: McpAppHostSessionState;
26
+ createdAt: string;
27
+ lastSeenAt: string;
28
+ fallbackReason: string | null;
29
+ lastExternalOpenAt: string | null;
30
+ }
31
+
32
+ export interface McpAppHostSnapshot {
33
+ runtimeEnabled: boolean;
34
+ activeSessionId: string | null;
35
+ sessions: McpAppHostSession[];
36
+ capabilities: McpAppHostCapability[];
37
+ metrics: {
38
+ hostedOpens: number;
39
+ fallbackTotal: number;
40
+ fallbackByReason: Record<string, number>;
41
+ };
42
+ }
43
+
44
+ export interface McpAppCandidateInput {
45
+ sourceServer: string | null;
46
+ sourceTool: string;
47
+ url: string;
48
+ inferredType: string;
49
+ keyHint: string;
50
+ }
51
+
52
+ export interface McpAppHostRoutingResult {
53
+ mode: 'hosted' | 'fallback';
54
+ reasonCode: string;
55
+ trustedDomain: boolean;
56
+ capability: McpAppHostCapability;
57
+ session: McpAppHostSession | null;
58
+ }
59
+
60
+ // Use a local config directory instead of PMX_CONFIG_DIR
61
+ const PMX_CANVAS_CONFIG_DIR = join(homedir(), '.pmx-canvas');
62
+
63
+ function ensureConfigDir(): void {
64
+ if (!existsSync(PMX_CANVAS_CONFIG_DIR)) {
65
+ mkdirSync(PMX_CANVAS_CONFIG_DIR, { recursive: true, mode: 0o700 });
66
+ }
67
+ }
68
+
69
+ const DEFAULT_MCP_APP_HOST_STATE_FILE = join(
70
+ PMX_CANVAS_CONFIG_DIR,
71
+ 'workbench',
72
+ 'mcp-app-host-state.json',
73
+ );
74
+
75
+ function sessionDiagLogPath(): string {
76
+ return String(process.env.PMX_SESSION_LOG || process.env.PMX_TEST_LOG || '').trim();
77
+ }
78
+
79
+ function sessionDiagLog(tag: string, payload: Record<string, unknown>): void {
80
+ const logPath = sessionDiagLogPath();
81
+ if (!logPath) return;
82
+ try {
83
+ appendFileSync(
84
+ logPath,
85
+ `${JSON.stringify({
86
+ ts: new Date().toISOString(),
87
+ scope: 'mcp-app-host',
88
+ tag,
89
+ ...payload,
90
+ })}\n`,
91
+ 'utf-8',
92
+ );
93
+ } catch (error) {
94
+ console.debug('[mcp-app-host] diagnostics logging failed', error);
95
+ }
96
+ }
97
+
98
+ function logMcpAppHostWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
99
+ console.warn(`[mcp-app-host] ${action}`, { error, ...(details ?? {}) });
100
+ }
101
+
102
+ function tryParseUrl(url: string): URL | null {
103
+ try {
104
+ return new URL(url);
105
+ } catch (error) {
106
+ console.debug('[mcp-app-host] invalid URL', { url, error });
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function mcpAppHostStateFilePath(): string {
112
+ const override = String(process.env.PMX_MCP_APP_HOST_STATE_FILE || '').trim();
113
+ return override.length > 0 ? override : DEFAULT_MCP_APP_HOST_STATE_FILE;
114
+ }
115
+ const MAX_ACTIVE_AND_BACKGROUND_SESSIONS = 24;
116
+ const MAX_CLOSED_SESSIONS = 32;
117
+
118
+ const capabilities = new Map<string, McpAppHostCapability>();
119
+ const sessions = new Map<string, McpAppHostSession>();
120
+ let activeSessionId: string | null = null;
121
+ let nextSessionId = 1;
122
+ let stateLoaded = false;
123
+ const metrics = {
124
+ hostedOpens: 0,
125
+ fallbackTotal: 0,
126
+ fallbackByReason: {} as Record<string, number>,
127
+ };
128
+
129
+ interface PersistedHostState {
130
+ activeSessionId: string | null;
131
+ nextSessionId: number;
132
+ capabilities: McpAppHostCapability[];
133
+ sessions: McpAppHostSession[];
134
+ metrics?: {
135
+ hostedOpens?: number;
136
+ fallbackTotal?: number;
137
+ fallbackByReason?: Record<string, number>;
138
+ };
139
+ }
140
+
141
+ function normalizeServerName(value: string | null | undefined): string | null {
142
+ const normalized = String(value || '').trim();
143
+ return normalized.length > 0 ? normalized : null;
144
+ }
145
+
146
+ function nowIso(): string {
147
+ return new Date().toISOString();
148
+ }
149
+
150
+ function resetRuntimeMetrics(): void {
151
+ metrics.hostedOpens = 0;
152
+ metrics.fallbackTotal = 0;
153
+ for (const key of Object.keys(metrics.fallbackByReason)) {
154
+ delete metrics.fallbackByReason[key];
155
+ }
156
+ }
157
+
158
+ function clearPersistedRuntimeSessionsOnLoad(): void {
159
+ const hadSessions = sessions.size;
160
+ const hadMetrics =
161
+ metrics.hostedOpens > 0 ||
162
+ metrics.fallbackTotal > 0 ||
163
+ Object.keys(metrics.fallbackByReason).length > 0;
164
+ if (hadSessions === 0 && !activeSessionId && !hadMetrics) return;
165
+
166
+ sessions.clear();
167
+ activeSessionId = null;
168
+ nextSessionId = 1;
169
+ resetRuntimeMetrics();
170
+ sessionDiagLog('reset-persisted-runtime-state', {
171
+ clearedSessions: hadSessions,
172
+ clearedMetrics: hadMetrics,
173
+ });
174
+ persistState();
175
+ }
176
+
177
+ function ensureStateLoaded(): void {
178
+ if (stateLoaded) return;
179
+ stateLoaded = true;
180
+
181
+ try {
182
+ const stateFile = mcpAppHostStateFilePath();
183
+ if (!existsSync(stateFile)) {
184
+ return;
185
+ }
186
+ const raw = readFileSync(stateFile, 'utf-8');
187
+ const parsed = JSON.parse(raw) as PersistedHostState;
188
+ if (Array.isArray(parsed.capabilities)) {
189
+ for (const entry of parsed.capabilities) {
190
+ if (!entry || typeof entry !== 'object') continue;
191
+ if (typeof entry.serverName !== 'string' || entry.serverName.trim().length === 0) continue;
192
+ const normalized: McpAppHostCapability = {
193
+ serverName: entry.serverName.trim(),
194
+ state:
195
+ entry.state === 'supported' ||
196
+ entry.state === 'unsupported' ||
197
+ entry.state === 'degraded'
198
+ ? entry.state
199
+ : 'degraded',
200
+ reasonCode:
201
+ typeof entry.reasonCode === 'string' && entry.reasonCode.trim().length > 0
202
+ ? entry.reasonCode.trim()
203
+ : 'unknown',
204
+ runtimeReady: entry.runtimeReady === true,
205
+ serverSupportsHost: entry.serverSupportsHost === true,
206
+ updatedAt:
207
+ typeof entry.updatedAt === 'string' && entry.updatedAt.trim().length > 0
208
+ ? entry.updatedAt
209
+ : nowIso(),
210
+ };
211
+ capabilities.set(normalized.serverName, normalized);
212
+ }
213
+ }
214
+
215
+ if (Array.isArray(parsed.sessions)) {
216
+ for (const entry of parsed.sessions) {
217
+ if (!entry || typeof entry !== 'object') continue;
218
+ if (typeof entry.sessionId !== 'string' || entry.sessionId.trim().length === 0) continue;
219
+ if (typeof entry.url !== 'string' || entry.url.trim().length === 0) continue;
220
+ const normalized: McpAppHostSession = {
221
+ sessionId: entry.sessionId.trim(),
222
+ sourceServer: normalizeServerName(entry.sourceServer),
223
+ sourceTool:
224
+ typeof entry.sourceTool === 'string' && entry.sourceTool.trim().length > 0
225
+ ? entry.sourceTool.trim()
226
+ : 'mcp-tool',
227
+ url: entry.url.trim(),
228
+ inferredType:
229
+ typeof entry.inferredType === 'string' && entry.inferredType.trim().length > 0
230
+ ? entry.inferredType.trim()
231
+ : 'mcp-app',
232
+ trustedDomain: entry.trustedDomain === true,
233
+ state:
234
+ entry.state === 'active' || entry.state === 'background' || entry.state === 'closed'
235
+ ? entry.state
236
+ : 'background',
237
+ createdAt:
238
+ typeof entry.createdAt === 'string' && entry.createdAt.trim().length > 0
239
+ ? entry.createdAt
240
+ : nowIso(),
241
+ lastSeenAt:
242
+ typeof entry.lastSeenAt === 'string' && entry.lastSeenAt.trim().length > 0
243
+ ? entry.lastSeenAt
244
+ : nowIso(),
245
+ fallbackReason:
246
+ typeof entry.fallbackReason === 'string' && entry.fallbackReason.trim().length > 0
247
+ ? entry.fallbackReason
248
+ : null,
249
+ lastExternalOpenAt:
250
+ typeof entry.lastExternalOpenAt === 'string' &&
251
+ entry.lastExternalOpenAt.trim().length > 0
252
+ ? entry.lastExternalOpenAt
253
+ : null,
254
+ };
255
+ sessions.set(normalized.sessionId, normalized);
256
+ }
257
+ }
258
+
259
+ activeSessionId = normalizeServerName(parsed.activeSessionId);
260
+ if (activeSessionId && !sessions.has(activeSessionId)) {
261
+ activeSessionId = null;
262
+ }
263
+
264
+ if (typeof parsed.nextSessionId === 'number' && Number.isFinite(parsed.nextSessionId)) {
265
+ nextSessionId = Math.max(1, Math.floor(parsed.nextSessionId));
266
+ }
267
+
268
+ if (parsed.metrics && typeof parsed.metrics === 'object') {
269
+ const loadedHostedOpens = Number(parsed.metrics.hostedOpens ?? 0);
270
+ const loadedFallbackTotal = Number(parsed.metrics.fallbackTotal ?? 0);
271
+ metrics.hostedOpens = Number.isFinite(loadedHostedOpens)
272
+ ? Math.max(0, Math.floor(loadedHostedOpens))
273
+ : 0;
274
+ metrics.fallbackTotal = Number.isFinite(loadedFallbackTotal)
275
+ ? Math.max(0, Math.floor(loadedFallbackTotal))
276
+ : 0;
277
+ if (parsed.metrics.fallbackByReason && typeof parsed.metrics.fallbackByReason === 'object') {
278
+ for (const [reason, count] of Object.entries(parsed.metrics.fallbackByReason)) {
279
+ const normalizedReason = reason.trim();
280
+ if (!normalizedReason) continue;
281
+ const numeric = Number(count);
282
+ if (!Number.isFinite(numeric)) continue;
283
+ metrics.fallbackByReason[normalizedReason] = Math.max(0, Math.floor(numeric));
284
+ }
285
+ }
286
+ }
287
+
288
+ trimSessionHistory();
289
+ pruneNonEmbeddableHostedSessions();
290
+ clearPersistedRuntimeSessionsOnLoad();
291
+ } catch (error) {
292
+ logMcpAppHostWarning('load persisted state failed', error, {
293
+ stateFile: mcpAppHostStateFilePath(),
294
+ });
295
+ }
296
+ }
297
+
298
+ function ensurePersistDirectory(): void {
299
+ ensureConfigDir();
300
+ const dir = dirname(mcpAppHostStateFilePath());
301
+ if (!existsSync(dir)) {
302
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
303
+ }
304
+ }
305
+
306
+ function persistState(): void {
307
+ try {
308
+ ensurePersistDirectory();
309
+ const payload: PersistedHostState = {
310
+ activeSessionId,
311
+ nextSessionId,
312
+ capabilities: [...capabilities.values()],
313
+ sessions: [...sessions.values()],
314
+ metrics: {
315
+ hostedOpens: metrics.hostedOpens,
316
+ fallbackTotal: metrics.fallbackTotal,
317
+ fallbackByReason: { ...metrics.fallbackByReason },
318
+ },
319
+ };
320
+ writeFileSync(mcpAppHostStateFilePath(), JSON.stringify(payload, null, 2), 'utf-8');
321
+ } catch (error) {
322
+ logMcpAppHostWarning('persist state failed', error, {
323
+ stateFile: mcpAppHostStateFilePath(),
324
+ });
325
+ }
326
+ }
327
+
328
+ function hostAllowlistHosts(): string[] {
329
+ const envValue = String(process.env.PMX_MCP_APP_HOST_ALLOWLIST || '').trim();
330
+ if (!envValue) return [];
331
+ return envValue
332
+ .split(',')
333
+ .map((entry) => entry.trim().toLowerCase())
334
+ .filter(Boolean);
335
+ }
336
+
337
+ export function isTrustedMcpAppDomain(url: string): boolean {
338
+ const parsed = tryParseUrl(url);
339
+ if (!parsed) return false;
340
+ const host = parsed.hostname.toLowerCase();
341
+
342
+ if (host === 'modelcontextprotocol.io' || host.endsWith('.modelcontextprotocol.io')) {
343
+ return true;
344
+ }
345
+ if (host === 'excalidraw.com' || host.endsWith('.excalidraw.com')) {
346
+ return true;
347
+ }
348
+ if (host.includes('mcp-app') && host.endsWith('.vercel.app')) {
349
+ return true;
350
+ }
351
+ if (host.includes('mcp') && host.endsWith('.vercel.app')) {
352
+ return true;
353
+ }
354
+
355
+ const allowlist = hostAllowlistHosts();
356
+ return allowlist.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
357
+ }
358
+
359
+ function isHostRuntimeEnabled(): boolean {
360
+ const raw = String(process.env.PMX_MCP_APP_HOST_MODE || '')
361
+ .trim()
362
+ .toLowerCase();
363
+ if (!raw) return true;
364
+ return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'disabled');
365
+ }
366
+
367
+ function supportsHostSessionByHint(input: McpAppCandidateInput): boolean {
368
+ const sourceServer = String(input.sourceServer || '').toLowerCase();
369
+ const sourceTool = String(input.sourceTool || '').toLowerCase();
370
+ const keyHint = String(input.keyHint || '').toLowerCase();
371
+ const inferredType = String(input.inferredType || '').toLowerCase();
372
+
373
+ const keySuggestsApps =
374
+ /resource|resource_link|resourceurl|resource_url|app|uri|url|link|viewer|preview|canvas/.test(
375
+ keyHint,
376
+ );
377
+ const sourceSuggestsApps =
378
+ sourceServer.includes('mcp') ||
379
+ sourceServer.includes('excalidraw') ||
380
+ sourceTool.includes('mcp') ||
381
+ sourceTool.includes('excalidraw') ||
382
+ sourceTool.includes('app') ||
383
+ sourceTool.includes('resource') ||
384
+ sourceTool.includes('viewer');
385
+ const typeSuggestsApps =
386
+ inferredType === 'mcp-app' ||
387
+ inferredType === 'diagram' ||
388
+ inferredType === 'design' ||
389
+ inferredType.endsWith('viewer') ||
390
+ inferredType === 'app-surface';
391
+
392
+ return keySuggestsApps || sourceSuggestsApps || typeSuggestsApps;
393
+ }
394
+
395
+ function isLikelyEmbeddableMcpAppSurface(url: string): boolean {
396
+ const parsed = tryParseUrl(url);
397
+ if (!parsed) return false;
398
+ const host = parsed.hostname.toLowerCase();
399
+ const path = parsed.pathname.toLowerCase();
400
+ const href = parsed.toString().toLowerCase();
401
+
402
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
403
+
404
+ if (host === 'modelcontextprotocol.io' || host.endsWith('.modelcontextprotocol.io')) {
405
+ return false;
406
+ }
407
+
408
+ if (/\.(?:md|txt|json|pdf|csv|yaml|yml)(?:$|[?#])/.test(href)) return false;
409
+ if (path.includes('/docs/') || path.startsWith('/docs')) return false;
410
+ if (path.includes('/blog/') || path.startsWith('/blog')) return false;
411
+
412
+ if (host === 'excalidraw.com' || host.endsWith('.excalidraw.com')) return true;
413
+ if (host.includes('mcp-app') && host.endsWith('.vercel.app')) return true;
414
+ if (host.includes('mcp') && host.endsWith('.vercel.app')) return true;
415
+
416
+ const allowlist = hostAllowlistHosts();
417
+ if (allowlist.some((allowed) => host === allowed || host.endsWith(`.${allowed}`))) return true;
418
+
419
+ return false;
420
+ }
421
+
422
+ function nextHostSessionId(): string {
423
+ const value = `app-${Date.now().toString(36)}-${nextSessionId.toString(36)}`;
424
+ nextSessionId += 1;
425
+ return value;
426
+ }
427
+
428
+ function sortedSessions(includeClosed = true): McpAppHostSession[] {
429
+ const entries = [...sessions.values()];
430
+ const filtered = includeClosed ? entries : entries.filter((entry) => entry.state !== 'closed');
431
+ return filtered.sort((left, right) => {
432
+ if (left.state === 'active' && right.state !== 'active') return -1;
433
+ if (left.state !== 'active' && right.state === 'active') return 1;
434
+ return right.lastSeenAt.localeCompare(left.lastSeenAt);
435
+ });
436
+ }
437
+
438
+ function trimSessionHistory(): void {
439
+ const activeAndBackground = sortedSessions(false);
440
+ const closed = sortedSessions(true).filter((entry) => entry.state === 'closed');
441
+
442
+ for (const entry of activeAndBackground.slice(MAX_ACTIVE_AND_BACKGROUND_SESSIONS)) {
443
+ sessions.delete(entry.sessionId);
444
+ if (activeSessionId === entry.sessionId) activeSessionId = null;
445
+ }
446
+ for (const entry of closed.slice(MAX_CLOSED_SESSIONS)) {
447
+ sessions.delete(entry.sessionId);
448
+ if (activeSessionId === entry.sessionId) activeSessionId = null;
449
+ }
450
+ }
451
+
452
+ function pruneNonEmbeddableHostedSessions(): void {
453
+ let changed = false;
454
+ for (const [sessionId, session] of sessions.entries()) {
455
+ if (session.state === 'closed') continue;
456
+ if (isLikelyEmbeddableMcpAppSurface(session.url)) continue;
457
+ sessionDiagLog('prune-non-embeddable', {
458
+ sessionId,
459
+ sourceServer: session.sourceServer,
460
+ sourceTool: session.sourceTool,
461
+ inferredType: session.inferredType,
462
+ url: session.url,
463
+ });
464
+ sessions.delete(sessionId);
465
+ if (activeSessionId === sessionId) {
466
+ activeSessionId = null;
467
+ }
468
+ changed = true;
469
+ }
470
+
471
+ if (!activeSessionId) {
472
+ const next = sortedSessions(false).find((entry) => entry.state !== 'closed');
473
+ if (next) {
474
+ activateSession(next.sessionId);
475
+ changed = true;
476
+ }
477
+ }
478
+
479
+ if (changed) {
480
+ trimSessionHistory();
481
+ persistState();
482
+ }
483
+ }
484
+
485
+ function upsertCapability(next: McpAppHostCapability): McpAppHostCapability {
486
+ ensureStateLoaded();
487
+ const normalized: McpAppHostCapability = {
488
+ ...next,
489
+ serverName: next.serverName.trim(),
490
+ reasonCode: next.reasonCode.trim() || 'unknown',
491
+ updatedAt: next.updatedAt || nowIso(),
492
+ };
493
+ capabilities.set(normalized.serverName, normalized);
494
+ persistState();
495
+ return normalized;
496
+ }
497
+
498
+ export function registerMcpAppHostCapability(input: {
499
+ serverName: string;
500
+ state: McpAppHostCapabilityState;
501
+ reasonCode: string;
502
+ runtimeReady: boolean;
503
+ serverSupportsHost: boolean;
504
+ }): McpAppHostCapability {
505
+ return upsertCapability({
506
+ serverName: input.serverName,
507
+ state: input.state,
508
+ reasonCode: input.reasonCode,
509
+ runtimeReady: input.runtimeReady,
510
+ serverSupportsHost: input.serverSupportsHost,
511
+ updatedAt: nowIso(),
512
+ });
513
+ }
514
+
515
+ const KNOWN_MCP_APP_HOST_SERVERS: ReadonlySet<string> = new Set(['excalidraw', 'json-render']);
516
+
517
+ export function preRegisterKnownMcpAppHostCapabilities(serverNames: string[]): void {
518
+ ensureStateLoaded();
519
+ const runtimeReady = isHostRuntimeEnabled();
520
+ for (const name of serverNames) {
521
+ if (!KNOWN_MCP_APP_HOST_SERVERS.has(name)) continue;
522
+ const existing = capabilities.get(name);
523
+ if (existing?.state === 'supported') continue;
524
+ upsertCapability({
525
+ serverName: name,
526
+ state: runtimeReady ? 'supported' : 'degraded',
527
+ reasonCode: runtimeReady ? 'startup_preregistered' : 'runtime_disabled',
528
+ runtimeReady,
529
+ serverSupportsHost: true,
530
+ updatedAt: nowIso(),
531
+ });
532
+ sessionDiagLog('preregister-capability', { serverName: name, runtimeReady });
533
+ }
534
+ }
535
+
536
+ function resolveCapabilityForCandidate(
537
+ input: McpAppCandidateInput,
538
+ trustedDomain: boolean,
539
+ ): McpAppHostCapability {
540
+ const runtimeReady = isHostRuntimeEnabled();
541
+ const serverName = normalizeServerName(input.sourceServer);
542
+ const serverSupportsHost = supportsHostSessionByHint(input);
543
+ const embeddableSurface = isLikelyEmbeddableMcpAppSurface(input.url);
544
+
545
+ if (!serverName) {
546
+ return {
547
+ serverName: 'unknown',
548
+ state: 'degraded',
549
+ reasonCode: 'missing_server_name',
550
+ runtimeReady,
551
+ serverSupportsHost,
552
+ updatedAt: nowIso(),
553
+ };
554
+ }
555
+
556
+ if (!runtimeReady) {
557
+ return upsertCapability({
558
+ serverName,
559
+ state: 'degraded',
560
+ reasonCode: 'runtime_disabled',
561
+ runtimeReady,
562
+ serverSupportsHost,
563
+ updatedAt: nowIso(),
564
+ });
565
+ }
566
+
567
+ if (!serverSupportsHost) {
568
+ return upsertCapability({
569
+ serverName,
570
+ state: 'degraded',
571
+ reasonCode: 'capability_unverified',
572
+ runtimeReady,
573
+ serverSupportsHost,
574
+ updatedAt: nowIso(),
575
+ });
576
+ }
577
+
578
+ if (!trustedDomain) {
579
+ return upsertCapability({
580
+ serverName,
581
+ state: 'degraded',
582
+ reasonCode: 'untrusted_domain',
583
+ runtimeReady,
584
+ serverSupportsHost,
585
+ updatedAt: nowIso(),
586
+ });
587
+ }
588
+
589
+ if (!embeddableSurface) {
590
+ return upsertCapability({
591
+ serverName,
592
+ state: 'degraded',
593
+ reasonCode: 'not_embeddable_surface',
594
+ runtimeReady,
595
+ serverSupportsHost,
596
+ updatedAt: nowIso(),
597
+ });
598
+ }
599
+
600
+ return upsertCapability({
601
+ serverName,
602
+ state: 'supported',
603
+ reasonCode: 'supported',
604
+ runtimeReady,
605
+ serverSupportsHost,
606
+ updatedAt: nowIso(),
607
+ });
608
+ }
609
+
610
+ function activateSession(sessionId: string): void {
611
+ ensureStateLoaded();
612
+ const target = sessions.get(sessionId);
613
+ if (!target || target.state === 'closed') return;
614
+ activeSessionId = sessionId;
615
+ const seenAt = nowIso();
616
+ for (const [key, session] of sessions.entries()) {
617
+ if (session.state === 'closed') continue;
618
+ if (key === sessionId) {
619
+ sessions.set(key, {
620
+ ...session,
621
+ state: 'active',
622
+ lastSeenAt: seenAt,
623
+ });
624
+ continue;
625
+ }
626
+ sessions.set(key, {
627
+ ...session,
628
+ state: 'background',
629
+ });
630
+ }
631
+ }
632
+
633
+ function findMatchingOpenSession(input: McpAppCandidateInput): McpAppHostSession | null {
634
+ const sourceServer = normalizeServerName(input.sourceServer);
635
+ for (const session of sessions.values()) {
636
+ if (session.state === 'closed') continue;
637
+ if (session.url !== input.url) continue;
638
+ if (normalizeServerName(session.sourceServer) !== sourceServer) continue;
639
+ if (session.sourceTool !== input.sourceTool.trim()) continue;
640
+ return session;
641
+ }
642
+ return null;
643
+ }
644
+
645
+ function registerFallback(reasonCode: string): void {
646
+ metrics.fallbackTotal += 1;
647
+ const normalizedReason = reasonCode.trim() || 'fallback';
648
+ metrics.fallbackByReason[normalizedReason] =
649
+ (metrics.fallbackByReason[normalizedReason] ?? 0) + 1;
650
+ }
651
+
652
+ export function routeMcpAppCandidateToHost(input: McpAppCandidateInput): McpAppHostRoutingResult {
653
+ ensureStateLoaded();
654
+
655
+ const trustedDomain = isTrustedMcpAppDomain(input.url);
656
+ const capability = resolveCapabilityForCandidate(input, trustedDomain);
657
+ sessionDiagLog('route-candidate', {
658
+ sourceServer: normalizeServerName(input.sourceServer),
659
+ sourceTool: input.sourceTool.trim() || 'mcp-tool',
660
+ inferredType: input.inferredType.trim() || 'mcp-app',
661
+ keyHint: input.keyHint.trim() || 'unknown',
662
+ url: input.url,
663
+ trustedDomain,
664
+ capabilityState: capability.state,
665
+ capabilityReason: capability.reasonCode,
666
+ });
667
+
668
+ if (capability.state !== 'supported') {
669
+ registerFallback(capability.reasonCode);
670
+ persistState();
671
+ sessionDiagLog('route-fallback', {
672
+ sourceServer: normalizeServerName(input.sourceServer),
673
+ sourceTool: input.sourceTool.trim() || 'mcp-tool',
674
+ url: input.url,
675
+ reasonCode: capability.reasonCode,
676
+ trustedDomain,
677
+ });
678
+ return {
679
+ mode: 'fallback',
680
+ reasonCode: capability.reasonCode,
681
+ trustedDomain,
682
+ capability,
683
+ session: null,
684
+ };
685
+ }
686
+
687
+ const normalizedSourceServer = normalizeServerName(input.sourceServer);
688
+ const sourceTool = input.sourceTool.trim() || 'mcp-tool';
689
+ const existing = findMatchingOpenSession(input);
690
+ let session: McpAppHostSession;
691
+ if (existing) {
692
+ session = {
693
+ ...existing,
694
+ inferredType: input.inferredType.trim() || existing.inferredType,
695
+ trustedDomain,
696
+ fallbackReason: null,
697
+ lastSeenAt: nowIso(),
698
+ };
699
+ sessions.set(session.sessionId, session);
700
+ } else {
701
+ session = {
702
+ sessionId: nextHostSessionId(),
703
+ sourceServer: normalizedSourceServer,
704
+ sourceTool,
705
+ url: input.url,
706
+ inferredType: input.inferredType.trim() || 'mcp-app',
707
+ trustedDomain,
708
+ state: 'background',
709
+ createdAt: nowIso(),
710
+ lastSeenAt: nowIso(),
711
+ fallbackReason: null,
712
+ lastExternalOpenAt: null,
713
+ };
714
+ sessions.set(session.sessionId, session);
715
+ }
716
+
717
+ activateSession(session.sessionId);
718
+ metrics.hostedOpens += 1;
719
+ trimSessionHistory();
720
+ persistState();
721
+ sessionDiagLog('route-hosted', {
722
+ sessionId: session.sessionId,
723
+ sourceServer: session.sourceServer,
724
+ sourceTool: session.sourceTool,
725
+ inferredType: session.inferredType,
726
+ url: session.url,
727
+ });
728
+
729
+ return {
730
+ mode: 'hosted',
731
+ reasonCode: 'supported',
732
+ trustedDomain,
733
+ capability,
734
+ session: sessions.get(session.sessionId) ?? session,
735
+ };
736
+ }
737
+
738
+ export function focusMcpAppHostSession(sessionId: string): McpAppHostSession | null {
739
+ ensureStateLoaded();
740
+ const normalized = sessionId.trim();
741
+ if (!normalized) return null;
742
+ const session = sessions.get(normalized);
743
+ if (!session || session.state === 'closed') return null;
744
+ activateSession(normalized);
745
+ persistState();
746
+ sessionDiagLog('focus-session', { sessionId: normalized, found: true });
747
+ return sessions.get(normalized) ?? null;
748
+ }
749
+
750
+ export function closeMcpAppHostSession(sessionId: string): McpAppHostSession | null {
751
+ ensureStateLoaded();
752
+ const normalized = sessionId.trim();
753
+ if (!normalized) return null;
754
+ const session = sessions.get(normalized);
755
+ if (!session || session.state === 'closed') return null;
756
+ sessions.set(normalized, {
757
+ ...session,
758
+ state: 'closed',
759
+ lastSeenAt: nowIso(),
760
+ });
761
+ if (activeSessionId === normalized) {
762
+ activeSessionId = null;
763
+ const next = sortedSessions(false).find((entry) => entry.state !== 'closed');
764
+ if (next) {
765
+ activateSession(next.sessionId);
766
+ }
767
+ }
768
+ trimSessionHistory();
769
+ persistState();
770
+ sessionDiagLog('close-session', { sessionId: normalized, found: true });
771
+ return sessions.get(normalized) ?? null;
772
+ }
773
+
774
+ export function markMcpAppHostSessionOpenedExternally(sessionId: string): McpAppHostSession | null {
775
+ ensureStateLoaded();
776
+ const normalized = sessionId.trim();
777
+ if (!normalized) return null;
778
+ const session = sessions.get(normalized);
779
+ if (!session) return null;
780
+ sessions.set(normalized, {
781
+ ...session,
782
+ lastExternalOpenAt: nowIso(),
783
+ });
784
+ persistState();
785
+ sessionDiagLog('open-external', { sessionId: normalized });
786
+ return sessions.get(normalized) ?? null;
787
+ }
788
+
789
+ export function listMcpAppHostSessions(options?: { includeClosed?: boolean }): McpAppHostSession[] {
790
+ ensureStateLoaded();
791
+ const includeClosed = options?.includeClosed === true;
792
+ return sortedSessions(includeClosed).map((entry) => ({ ...entry }));
793
+ }
794
+
795
+ export function getMcpAppHostSnapshot(): McpAppHostSnapshot {
796
+ ensureStateLoaded();
797
+ const runtimeEnabled = isHostRuntimeEnabled();
798
+ const sessionList = sortedSessions(true).map((entry) => ({ ...entry }));
799
+ const capabilityList = [...capabilities.values()]
800
+ .map((entry) => ({ ...entry }))
801
+ .sort((left, right) => left.serverName.localeCompare(right.serverName));
802
+
803
+ return {
804
+ runtimeEnabled,
805
+ activeSessionId,
806
+ sessions: sessionList,
807
+ capabilities: capabilityList,
808
+ metrics: {
809
+ hostedOpens: metrics.hostedOpens,
810
+ fallbackTotal: metrics.fallbackTotal,
811
+ fallbackByReason: { ...metrics.fallbackByReason },
812
+ },
813
+ };
814
+ }