@tuongaz/seeflow 0.1.77 → 0.1.80

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 (107) hide show
  1. package/README.md +40 -0
  2. package/dist/web/assets/{architectureDiagram-3BPJPVTR-D5iHwVvy.js → architectureDiagram-3BPJPVTR-id0XTZQC.js} +1 -1
  3. package/dist/web/assets/{blockDiagram-GPEHLZMM-MAYYm7FM.js → blockDiagram-GPEHLZMM-Cjvfg0ZP.js} +1 -1
  4. package/dist/web/assets/{c4Diagram-AAUBKEIU-7P7yfHg1.js → c4Diagram-AAUBKEIU-Dyq-0e8Q.js} +1 -1
  5. package/dist/web/assets/channel-Ajb6KiL3.js +1 -0
  6. package/dist/web/assets/{chart-C68vupBE.js → chart-DuTGW-Dj.js} +1 -1
  7. package/dist/web/assets/{chunk-2J33WTMH-Bb4cSusI.js → chunk-2J33WTMH-DsD65OzD.js} +1 -1
  8. package/dist/web/assets/{chunk-4BX2VUAB-DXYpcpTh.js → chunk-4BX2VUAB-BpytKE8P.js} +1 -1
  9. package/dist/web/assets/{chunk-55IACEB6-BxuYKDnf.js → chunk-55IACEB6-DIILAUq9.js} +1 -1
  10. package/dist/web/assets/{chunk-727SXJPM-DbWlxAr2.js → chunk-727SXJPM-C4ih-gTo.js} +1 -1
  11. package/dist/web/assets/{chunk-AQP2D5EJ-DT8S1q80.js → chunk-AQP2D5EJ-BsYoWdVM.js} +1 -1
  12. package/dist/web/assets/{chunk-FMBD7UC4-Dc0wDuZz.js → chunk-FMBD7UC4-Db6L0z4p.js} +1 -1
  13. package/dist/web/assets/{chunk-ND2GUHAM-CqLLK6H0.js → chunk-ND2GUHAM-BNLqZYMx.js} +1 -1
  14. package/dist/web/assets/{chunk-QZHKN3VN-CxF7nkDI.js → chunk-QZHKN3VN-DL5PK45j.js} +1 -1
  15. package/dist/web/assets/classDiagram-4FO5ZUOK-Cgw6ezRo.js +1 -0
  16. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Cgw6ezRo.js +1 -0
  17. package/dist/web/assets/{code-block-DR9fiK_U.js → code-block-C1SJv-Al.js} +1 -1
  18. package/dist/web/assets/{cose-bilkent-S5V4N54A-BflFbtY2.js → cose-bilkent-S5V4N54A-ChX5nR0f.js} +1 -1
  19. package/dist/web/assets/{dagre-BM42HDAG-BJ5UdyYS.js → dagre-BM42HDAG-BXeL3fEN.js} +1 -1
  20. package/dist/web/assets/{diagram-2AECGRRQ-D0M8fCf7.js → diagram-2AECGRRQ-B6WtmEP-.js} +1 -1
  21. package/dist/web/assets/{diagram-5GNKFQAL-D67gAMS4.js → diagram-5GNKFQAL-SXs7ALwM.js} +1 -1
  22. package/dist/web/assets/{diagram-KO2AKTUF-XX62HBG-.js → diagram-KO2AKTUF-D5zylPYo.js} +1 -1
  23. package/dist/web/assets/{diagram-LMA3HP47-DCFq3Oac.js → diagram-LMA3HP47-CByIUlQF.js} +1 -1
  24. package/dist/web/assets/{diagram-OG6HWLK6-Be392NCN.js → diagram-OG6HWLK6-BH1MfUqV.js} +1 -1
  25. package/dist/web/assets/{erDiagram-TEJ5UH35-DP4eP0as.js → erDiagram-TEJ5UH35-BOOnRFBh.js} +1 -1
  26. package/dist/web/assets/{flowDiagram-I6XJVG4X-Ch1GVJ9R.js → flowDiagram-I6XJVG4X-BynWDHJP.js} +1 -1
  27. package/dist/web/assets/{ganttDiagram-6RSMTGT7-DtvkTizu.js → ganttDiagram-6RSMTGT7-Cgq_djyN.js} +1 -1
  28. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-YGcuBgb9.js → gitGraphDiagram-PVQCEYII-ciGSgmfT.js} +1 -1
  29. package/dist/web/assets/index-DiakpHyc.js +8619 -0
  30. package/dist/web/assets/{index-DljfurDC.css → index-fl8DS9WO.css} +1 -1
  31. package/dist/web/assets/{index.es-jrsJPbYZ.js → index.es-C7TtaIfa.js} +1 -1
  32. package/dist/web/assets/{infoDiagram-5YYISTIA-wce0BORz.js → infoDiagram-5YYISTIA-DqMb3_c-.js} +1 -1
  33. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-u2MvPgdW.js → ishikawaDiagram-YF4QCWOH-CAO6KqQU.js} +1 -1
  34. package/dist/web/assets/{journeyDiagram-JHISSGLW-BsOyrTiA.js → journeyDiagram-JHISSGLW-Di8MsLTo.js} +1 -1
  35. package/dist/web/assets/{jspdf.es.min-ptMERvnN.js → jspdf.es.min-Cq4dY-lT.js} +3 -3
  36. package/dist/web/assets/{kanban-definition-UN3LZRKU-BaraYV9q.js → kanban-definition-UN3LZRKU-ClOmVNcX.js} +1 -1
  37. package/dist/web/assets/{linear-BVqXcDUJ.js → linear-B3OKBKaT.js} +1 -1
  38. package/dist/web/assets/{markdown-DqP0Cywq.js → markdown-Dg8NEx1K.js} +1 -1
  39. package/dist/web/assets/{mermaid.core-CakR_vo1.js → mermaid.core-Bw-m7bH-.js} +4 -4
  40. package/dist/web/assets/{mindmap-definition-RKZ34NQL-CO5AsZw3.js → mindmap-definition-RKZ34NQL-CUBA1zfc.js} +1 -1
  41. package/dist/web/assets/{pieDiagram-4H26LBE5-CiDJY-kx.js → pieDiagram-4H26LBE5-Dux5HvSU.js} +1 -1
  42. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BS6oN3s_.js → quadrantDiagram-W4KKPZXB-DU3gQGo3.js} +1 -1
  43. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNbUR_FF.js → requirementDiagram-4Y6WPE33-CD3A_U9j.js} +1 -1
  44. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-0Esj5uzm.js → sankeyDiagram-5OEKKPKP-Cd4mc26P.js} +1 -1
  45. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-DR3U38Zi.js → sequenceDiagram-3UESZ5HK-Da0iOMgq.js} +1 -1
  46. package/dist/web/assets/{stateDiagram-AJRCARHV-C50RQjWe.js → stateDiagram-AJRCARHV-P94LaOD2.js} +1 -1
  47. package/dist/web/assets/stateDiagram-v2-BHNVJYJU--JLHF28o.js +1 -0
  48. package/dist/web/assets/{time-C_2J9tFX.js → time-0JEErjjJ.js} +1 -1
  49. package/dist/web/assets/{timeline-definition-PNZ67QCA-BQXyo2r_.js → timeline-definition-PNZ67QCA-BqAYomix.js} +1 -1
  50. package/dist/web/assets/{vennDiagram-CIIHVFJN-DZJ8M3EA.js → vennDiagram-CIIHVFJN-BWuPhfIM.js} +1 -1
  51. package/dist/web/assets/{wardley-L42UT6IY-B96HtW3i.js → wardley-L42UT6IY-iiGkgUQj.js} +1 -1
  52. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BHkQ79WC.js → wardleyDiagram-YWT4CUSO-CtqzFQXL.js} +1 -1
  53. package/dist/web/assets/{xychartDiagram-2RQKCTM6-B_f8koGI.js → xychartDiagram-2RQKCTM6-BGrOXndI.js} +1 -1
  54. package/dist/web/index.html +2 -2
  55. package/examples/component-showcase/seeflow.json +6 -0
  56. package/examples/ecommerce-platform/seeflow.json +6 -0
  57. package/examples/order-pipeline/seeflow.json +6 -0
  58. package/package.json +1 -1
  59. package/src/api.ts +739 -94
  60. package/src/cli-e2e.ts +24 -13
  61. package/src/cli-helpers.ts +26 -0
  62. package/src/cli-manifest.ts +330 -87
  63. package/src/cli-ops.ts +56 -2
  64. package/src/cli.ts +228 -81
  65. package/src/cors.ts +93 -0
  66. package/src/jq-filter.ts +253 -0
  67. package/src/mcp-shim.ts +114 -7
  68. package/src/mcp-ui.ts +126 -0
  69. package/src/mcp.ts +258 -97
  70. package/src/node-files.ts +18 -7
  71. package/src/operations.ts +68 -32
  72. package/src/project-scanner.ts +105 -0
  73. package/src/registry.ts +79 -18
  74. package/src/route-resolve.ts +41 -0
  75. package/src/schema.ts +54 -0
  76. package/src/server.ts +24 -3
  77. package/src/slugify.ts +16 -0
  78. package/dist/web/assets/channel-BjsQQK93.js +0 -1
  79. package/dist/web/assets/classDiagram-4FO5ZUOK-p3FY5uNC.js +0 -1
  80. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-p3FY5uNC.js +0 -1
  81. package/dist/web/assets/index-Bg3PU4Ev.js +0 -8614
  82. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BbNrmkIR.js +0 -1
  83. /package/examples/component-showcase/{flow.json → flows/main/flow.json} +0 -0
  84. /package/examples/component-showcase/{nodes → flows/main/nodes}/chart/spec.json +0 -0
  85. /package/examples/component-showcase/{nodes → flows/main/nodes}/counter/spec.json +0 -0
  86. /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/actions/refresh.ts +0 -0
  87. /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/spec.json +0 -0
  88. /package/examples/component-showcase/{nodes → flows/main/nodes}/form/spec.json +0 -0
  89. /package/examples/component-showcase/{style.json → flows/main/style.json} +0 -0
  90. /package/examples/ecommerce-platform/{flow.json → flows/main/flow.json} +0 -0
  91. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-3zFtHg6ENc/detail.md +0 -0
  92. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-5F424NWbEu/detail.md +0 -0
  93. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-CbwYqb7NfB/detail.md +0 -0
  94. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-XwygzfKPZ5/view.html +0 -0
  95. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-fkptXw7uvs/detail.md +0 -0
  96. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-kwBY8YPmYM/detail.md +0 -0
  97. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-mPqan8rFYN/detail.md +0 -0
  98. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-yKrg9DV5fJ/detail.md +0 -0
  99. /package/examples/ecommerce-platform/{scripts → flows/main/scripts}/play.ts +0 -0
  100. /package/examples/ecommerce-platform/{style.json → flows/main/style.json} +0 -0
  101. /package/examples/order-pipeline/{flow.json → flows/main/flow.json} +0 -0
  102. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-GXTKUcE3ye/detail.md +0 -0
  103. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-XKIyds0TDg/detail.md +0 -0
  104. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-YOYiHJpY0i/detail.md +0 -0
  105. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-zUIH7WFnhK/detail.md +0 -0
  106. /package/examples/order-pipeline/{scripts → flows/main/scripts}/play.ts +0 -0
  107. /package/examples/order-pipeline/{style.json → flows/main/style.json} +0 -0
package/src/mcp.ts CHANGED
@@ -2,10 +2,19 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import {
3
3
  CallToolRequestSchema,
4
4
  type CallToolResult,
5
+ ListResourcesRequestSchema,
5
6
  ListToolsRequestSchema,
7
+ ReadResourceRequestSchema,
6
8
  } from '@modelcontextprotocol/sdk/types.js';
7
9
  import { type ZodTypeAny, z } from 'zod';
8
10
  import { zodToJsonSchema } from 'zod-to-json-schema';
11
+ import {
12
+ CANVAS_RESOURCE_MIME,
13
+ CANVAS_RESOURCE_URI,
14
+ type CanvasWidgetState,
15
+ canvasMeta,
16
+ readCanvasHtml,
17
+ } from './mcp-ui.ts';
9
18
  import {
10
19
  ConnectorPatchBodySchema,
11
20
  CreateProjectBodySchema,
@@ -27,8 +36,48 @@ import type { FlowWatcher } from './watcher.ts';
27
36
  export interface CreateMcpServerOptions {
28
37
  registry: Registry;
29
38
  watcher?: FlowWatcher;
39
+ /** Per-process token forwarded to the MCP App iframe via
40
+ * `_meta['openai/widgetState'].backendToken` so cross-origin requests
41
+ * from the sandboxed (`Origin: null`) iframe can carry it as
42
+ * `X-Seeflow-Token`. Same value as the one passed to
43
+ * `createApp({ token })`. Wired into canvas-bearing tool handlers; non-
44
+ * canvas tools ignore it. */
45
+ token?: string;
46
+ /** Reachable loopback URL of the studio HTTP backend (e.g.
47
+ * `http://127.0.0.1:54321`). The MCP App iframe uses it as the REST
48
+ * base URL to load flow data. When unset, canvas-bearing tools omit
49
+ * `_meta` entirely so non-Apps hosts still work (the iframe can't
50
+ * reach a backend without both `httpUrl` and `token`). */
51
+ httpUrl?: string;
30
52
  }
31
53
 
54
+ /** Subset of `CreateMcpServerOptions` that the canvas-bearing tool
55
+ * handlers need at call time. Built once in `createMcpServer` and
56
+ * passed into `buildTools` so the closures inside each handler can
57
+ * attach `_meta` without re-reading the outer options. */
58
+ interface ToolContext {
59
+ registry: Registry;
60
+ token?: string;
61
+ httpUrl?: string;
62
+ }
63
+
64
+ // Distributive Omit so the discriminated union arms stay distinct after
65
+ // stripping the host-only `backendUrl` / `backendToken` keys.
66
+ type DistOmit<T, K extends keyof never> = T extends unknown ? Omit<T, K> : never;
67
+ type CanvasWidgetStateInput = DistOmit<CanvasWidgetState, 'backendUrl' | 'backendToken'>;
68
+
69
+ /** Build the `_meta` block for a canvas-bearing tool result, when both
70
+ * `httpUrl` and `token` are configured. Returns `undefined` if either
71
+ * is missing (e.g. proxy mode in `mcp-shim.ts`, or tests that bypass
72
+ * the shim) so non-Apps callers still get a plain JSON-only result. */
73
+ const canvasMetaFor = (
74
+ ctx: ToolContext,
75
+ state: CanvasWidgetStateInput,
76
+ ): Record<string, unknown> | undefined => {
77
+ if (!ctx.httpUrl || !ctx.token) return undefined;
78
+ return canvasMeta({ ...state, backendUrl: ctx.httpUrl, backendToken: ctx.token });
79
+ };
80
+
32
81
  // Tools are pushed into this in-memory list inside `createMcpServer`. Each
33
82
  // tool has a tiny one-sentence description, a JSON Schema for its input
34
83
  // (built from existing Zod schemas via zod-to-json-schema where reuse is
@@ -56,9 +105,13 @@ const inputSchemaFromZod = (schema: ZodTypeAny): Record<string, unknown> => {
56
105
  return rest.type === 'object' ? rest : { type: 'object', ...rest };
57
106
  };
58
107
 
59
- const okResult = (value: unknown): CallToolResult => ({
60
- content: [{ type: 'text', text: JSON.stringify(value) }],
61
- });
108
+ const okResult = (value: unknown, meta?: Record<string, unknown>): CallToolResult => {
109
+ const result: CallToolResult = {
110
+ content: [{ type: 'text', text: JSON.stringify(value) }],
111
+ };
112
+ if (meta) result._meta = meta;
113
+ return result;
114
+ };
62
115
 
63
116
  // Error payloads (e.g. 'unknown demo', 'Failed to write demo file') still say
64
117
  // "demo" so the strings match the REST handlers in api.ts byte-for-byte.
@@ -69,55 +122,84 @@ const errorResult = (text: string): CallToolResult => ({
69
122
  content: [{ type: 'text', text }],
70
123
  });
71
124
 
72
- // Most MCP tools take a single flowId argument. Defined inline as plain
73
- // JSON Schema (rather than a one-off Zod schema) because there's no REST
74
- // counterpart to share with.
75
- const FLOW_ID_INPUT_SCHEMA = {
125
+ // Most flow-scoped MCP tools take { project, flow } to address a registered
126
+ // flow. Defined inline as plain JSON Schema (rather than a one-off Zod
127
+ // schema) because there's no REST counterpart to share with.
128
+ const PROJECT_FLOW_PROPERTIES = {
129
+ project: {
130
+ type: 'string',
131
+ minLength: 1,
132
+ description: 'Project slug (e.g. `order-pipeline`) addressing a registered project.',
133
+ },
134
+ flow: {
135
+ type: 'string',
136
+ minLength: 1,
137
+ description: 'Flow slug within that project (e.g. `main` or `retry`).',
138
+ },
139
+ } as const;
140
+
141
+ const FLOW_PROJECT_INPUT_SCHEMA = {
76
142
  type: 'object',
77
- properties: { flowId: { type: 'string', minLength: 1 } },
78
- required: ['flowId'],
143
+ properties: { ...PROJECT_FLOW_PROPERTIES },
144
+ required: ['project', 'flow'],
79
145
  additionalProperties: false,
80
146
  } as const;
81
147
 
82
- const requireFlowId = (args: unknown): { flowId: string } | { error: string } => {
148
+ const requireProjectFlow = (
149
+ args: unknown,
150
+ ): { project: string; flow: string } | { error: string } => {
83
151
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
84
- return { error: 'Invalid arguments: expected an object with flowId' };
152
+ return { error: 'Invalid arguments: expected an object with project + flow' };
85
153
  }
86
- const { flowId } = args as { flowId?: unknown };
87
- if (typeof flowId !== 'string' || flowId.length === 0) {
88
- return { error: 'Invalid arguments: flowId must be a non-empty string' };
154
+ const { project, flow } = args as { project?: unknown; flow?: unknown };
155
+ if (typeof project !== 'string' || project.length === 0) {
156
+ return { error: 'Invalid arguments: project must be a non-empty string' };
89
157
  }
90
- return { flowId };
158
+ if (typeof flow !== 'string' || flow.length === 0) {
159
+ return { error: 'Invalid arguments: flow must be a non-empty string' };
160
+ }
161
+ return { project, flow };
91
162
  };
92
163
 
93
- // {flowId, nodeId} body shape shared by move + reorder + delete inputs.
94
- const FlowNodeIdBaseSchema = z.object({
95
- flowId: z.string().min(1),
164
+ // Project + flow slug pair used by every flow-scoped tool input as a Zod
165
+ // envelope. Each field carries its own description so the JSON Schema
166
+ // `tools/list` surfaces is self-documenting.
167
+ const ProjectFlowSchema = z.object({
168
+ project: z
169
+ .string()
170
+ .min(1)
171
+ .describe('Project slug (e.g. `order-pipeline`) addressing a registered project.'),
172
+ flow: z.string().min(1).describe('Flow slug within that project (e.g. `main` or `retry`).'),
173
+ });
174
+
175
+ // {project, flow, nodeId} body shape shared by move + reorder + delete inputs.
176
+ const FlowNodeIdBaseSchema = ProjectFlowSchema.extend({
96
177
  nodeId: z.string().min(1),
97
178
  });
98
179
 
99
- // add_node input: { flowId, node: <node payload> }. The inner `node` object is
100
- // loose here (additionalProperties=true via passthrough) because ResolvedFlowSchema
101
- // runs the full validation server-side after the new node is merged in.
102
- const AddNodeInputSchema = z.object({
103
- flowId: z.string().min(1),
180
+ // add_node input: { project, flow, node: <node payload> }. The inner `node`
181
+ // object is loose here (additionalProperties=true via passthrough) because
182
+ // ResolvedFlowSchema runs the full validation server-side after the new
183
+ // node is merged in.
184
+ const AddNodeInputSchema = ProjectFlowSchema.extend({
104
185
  node: z.record(z.unknown()),
105
186
  });
106
187
 
107
- // add_bulk input: { flowId, nodes?: [...], connectors?: [...] }. Same loose
108
- // per-item shape as add_node / add_connector — ResolvedFlowSchema runs once
109
- // over the whole merged graph server-side after the batch lands. The
188
+ // add_bulk input: { project, flow, nodes?: [...], connectors?: [...] }. Same
189
+ // loose per-item shape as add_node / add_connector — ResolvedFlowSchema runs
190
+ // once over the whole merged graph server-side after the batch lands. The
110
191
  // 100-per-kind cap and "at least one non-empty" invariant come from
111
192
  // FlowBulkBodyShape + flowBulkNonEmpty (the unrefined object + reusable
112
193
  // predicate exported by operations.ts) so the JSON Schema the agent
113
194
  // introspects stays a clean object — not an intersection.
114
195
  const AddBulkInputSchema = FlowBulkBodyShape.extend({
115
- flowId: z.string().min(1),
196
+ project: ProjectFlowSchema.shape.project,
197
+ flow: ProjectFlowSchema.shape.flow,
116
198
  }).refine(flowBulkNonEmpty, { message: FLOW_BULK_NON_EMPTY_MESSAGE });
117
199
 
118
200
  const DeleteNodeInputSchema = FlowNodeIdBaseSchema;
119
201
 
120
- // move_node input: { flowId, nodeId } extended with PositionBodySchema's
202
+ // move_node input: { project, flow, nodeId } extended with PositionBodySchema's
121
203
  // { x, y } fields so agents see one flat schema.
122
204
  const MoveNodeInputSchema = FlowNodeIdBaseSchema.extend({
123
205
  x: PositionBodySchema.shape.x,
@@ -125,8 +207,9 @@ const MoveNodeInputSchema = FlowNodeIdBaseSchema.extend({
125
207
  });
126
208
 
127
209
  // reorder_node input: each branch of the existing ReorderBodySchema
128
- // discriminated union extended with flowId/nodeId. Keeps the discriminator
129
- // on `op` so the emitted JSON Schema is an oneOf the agent can introspect.
210
+ // discriminated union extended with project/flow/nodeId. Keeps the
211
+ // discriminator on `op` so the emitted JSON Schema is an oneOf the agent
212
+ // can introspect.
130
213
  const ReorderNodeInputSchema = z.discriminatedUnion('op', [
131
214
  FlowNodeIdBaseSchema.extend({ op: z.literal('forward') }),
132
215
  FlowNodeIdBaseSchema.extend({ op: z.literal('backward') }),
@@ -138,40 +221,40 @@ const ReorderNodeInputSchema = z.discriminatedUnion('op', [
138
221
  }),
139
222
  ]);
140
223
 
141
- // patch_node input: { flowId, nodeId } merged with NodePatchBodySchema's
224
+ // patch_node input: { project, flow, nodeId } merged with NodePatchBodySchema's
142
225
  // optional fields. .extend() on the strict body schema preserves strict
143
226
  // mode, so unknown top-level keys still trip the Zod parse before any disk
144
227
  // IO — matching the REST handler's "Invalid node patch body" 400 path.
145
228
  const PatchNodeInputSchema = NodePatchBodySchema.extend({
146
- flowId: z.string().min(1),
229
+ project: ProjectFlowSchema.shape.project,
230
+ flow: ProjectFlowSchema.shape.flow,
147
231
  nodeId: z.string().min(1),
148
232
  });
149
233
 
150
- // add_connector input: { flowId, connector: <connector payload> }. The inner
151
- // `connector` object is loose (additionalProperties=true via z.record) because
152
- // ResolvedFlowSchema runs the full validation server-side after the new connector is
153
- // merged in (post-mutation parse catches dangling source/target refs and
154
- // kind-discriminator violations).
155
- const AddConnectorInputSchema = z.object({
156
- flowId: z.string().min(1),
234
+ // add_connector input: { project, flow, connector: <connector payload> }.
235
+ // The inner `connector` object is loose (additionalProperties=true via
236
+ // z.record) because ResolvedFlowSchema runs the full validation server-side
237
+ // after the new connector is merged in (post-mutation parse catches dangling
238
+ // source/target refs and kind-discriminator violations).
239
+ const AddConnectorInputSchema = ProjectFlowSchema.extend({
157
240
  connector: z.record(z.unknown()),
158
241
  });
159
242
 
160
- // patch_connector input: { flowId, connectorId } merged with the strict
161
- // ConnectorPatchBodySchema. .extend() preserves strict mode so unknown
162
- // top-level keys trip the Zod parse before any IO — matching the REST
163
- // handler's "Invalid connector patch body" 400 path.
243
+ // patch_connector input: { project, flow, connectorId } merged with the
244
+ // strict ConnectorPatchBodySchema. .extend() preserves strict mode so
245
+ // unknown top-level keys trip the Zod parse before any IO — matching the
246
+ // REST handler's "Invalid connector patch body" 400 path.
164
247
  const PatchConnectorInputSchema = ConnectorPatchBodySchema.extend({
165
- flowId: z.string().min(1),
248
+ project: ProjectFlowSchema.shape.project,
249
+ flow: ProjectFlowSchema.shape.flow,
166
250
  connectorId: z.string().min(1),
167
251
  });
168
252
 
169
- const DeleteConnectorInputSchema = z.object({
170
- flowId: z.string().min(1),
253
+ const DeleteConnectorInputSchema = ProjectFlowSchema.extend({
171
254
  connectorId: z.string().min(1),
172
255
  });
173
256
 
174
- const buildTools = (ops: Operations): McpTool[] => [
257
+ const buildTools = (ops: Operations, ctx: ToolContext): McpTool[] => [
175
258
  {
176
259
  name: 'seeflow_list_flows',
177
260
  description: 'List every flow registered with the studio.',
@@ -314,15 +397,25 @@ const buildTools = (ops: Operations): McpTool[] => [
314
397
  },
315
398
  {
316
399
  name: 'seeflow_get_flow',
317
- description: 'Get the full flow definition and on-disk state for a flowId.',
318
- inputSchema: FLOW_ID_INPUT_SCHEMA,
400
+ description: 'Get the full flow definition and on-disk state for a (project, flow) pair.',
401
+ inputSchema: FLOW_PROJECT_INPUT_SCHEMA,
319
402
  handler: async (args) => {
320
- const v = requireFlowId(args);
403
+ const v = requireProjectFlow(args);
321
404
  if ('error' in v) return errorResult(v.error);
322
- const result = await ops.getFlow(v.flowId);
405
+ const flowSlug = `${v.project}/${v.flow}`;
406
+ const result = await ops.getFlow(flowSlug);
323
407
  switch (result.kind) {
324
- case 'ok':
325
- return okResult(result.data);
408
+ case 'ok': {
409
+ const entry = ctx.registry.resolve(flowSlug);
410
+ const meta = entry
411
+ ? canvasMetaFor(ctx, {
412
+ kind: 'navigate',
413
+ projectSlug: entry.projectSlug,
414
+ flowSlug: entry.flowSlug,
415
+ })
416
+ : undefined;
417
+ return okResult(result.data, meta);
418
+ }
326
419
  case 'notFound':
327
420
  return errorResult('not found');
328
421
  case 'fileNotFound':
@@ -336,14 +429,24 @@ const buildTools = (ops: Operations): McpTool[] => [
336
429
  "Get a flow's nodes + connectors without inlining per-node file-backed " +
337
430
  'content (`detail`, `html`). Cheap topology read — pair with seeflow_get_node ' +
338
431
  "when you need a specific node's long-form body.",
339
- inputSchema: FLOW_ID_INPUT_SCHEMA,
432
+ inputSchema: FLOW_PROJECT_INPUT_SCHEMA,
340
433
  handler: async (args) => {
341
- const v = requireFlowId(args);
434
+ const v = requireProjectFlow(args);
342
435
  if ('error' in v) return errorResult(v.error);
343
- const result = await ops.getFlowGraph(v.flowId);
436
+ const flowSlug = `${v.project}/${v.flow}`;
437
+ const result = await ops.getFlowGraph(flowSlug);
344
438
  switch (result.kind) {
345
- case 'ok':
346
- return okResult(result.data);
439
+ case 'ok': {
440
+ const entry = ctx.registry.resolve(flowSlug);
441
+ const meta = entry
442
+ ? canvasMetaFor(ctx, {
443
+ kind: 'navigate',
444
+ projectSlug: entry.projectSlug,
445
+ flowSlug: entry.flowSlug,
446
+ })
447
+ : undefined;
448
+ return okResult(result.data, meta);
449
+ }
347
450
  case 'notFound':
348
451
  return errorResult('not found');
349
452
  case 'fileNotFound':
@@ -365,27 +468,34 @@ const buildTools = (ops: Operations): McpTool[] => [
365
468
  inputSchema: {
366
469
  type: 'object',
367
470
  properties: {
368
- flowId: { type: 'string', minLength: 1 },
471
+ ...PROJECT_FLOW_PROPERTIES,
369
472
  nodeId: { type: 'string', minLength: 1 },
370
473
  },
371
- required: ['flowId', 'nodeId'],
474
+ required: ['project', 'flow', 'nodeId'],
372
475
  additionalProperties: false,
373
476
  },
374
477
  handler: async (args) => {
375
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
376
- return errorResult('Invalid arguments: expected an object with flowId + nodeId');
377
- }
378
- const { flowId, nodeId } = args as { flowId?: unknown; nodeId?: unknown };
379
- if (typeof flowId !== 'string' || flowId.length === 0) {
380
- return errorResult('Invalid arguments: flowId must be a non-empty string');
381
- }
478
+ const v = requireProjectFlow(args);
479
+ if ('error' in v) return errorResult(v.error);
480
+ const { nodeId } = (args as { nodeId?: unknown }) ?? {};
382
481
  if (typeof nodeId !== 'string' || nodeId.length === 0) {
383
482
  return errorResult('Invalid arguments: nodeId must be a non-empty string');
384
483
  }
385
- const result = await ops.getNode(flowId, nodeId);
484
+ const flowSlug = `${v.project}/${v.flow}`;
485
+ const result = await ops.getNode(flowSlug, nodeId);
386
486
  switch (result.kind) {
387
- case 'ok':
388
- return okResult(result.data);
487
+ case 'ok': {
488
+ const entry = ctx.registry.resolve(flowSlug);
489
+ const meta = entry
490
+ ? canvasMetaFor(ctx, {
491
+ kind: 'navigate',
492
+ projectSlug: entry.projectSlug,
493
+ flowSlug: entry.flowSlug,
494
+ nodeId,
495
+ })
496
+ : undefined;
497
+ return okResult(result.data, meta);
498
+ }
389
499
  case 'notFound':
390
500
  return errorResult('not found');
391
501
  case 'fileNotFound':
@@ -412,8 +522,18 @@ const buildTools = (ops: Operations): McpTool[] => [
412
522
  }
413
523
  const result = await ops.registerFlow(parsed.data);
414
524
  switch (result.kind) {
415
- case 'ok':
416
- return okResult(result.data);
525
+ case 'ok': {
526
+ const entry = ctx.registry.resolve(result.data.id);
527
+ const meta = entry
528
+ ? canvasMetaFor(ctx, {
529
+ kind: 'create',
530
+ projectSlug: entry.projectSlug,
531
+ flowSlug: entry.flowSlug,
532
+ justCreated: true,
533
+ })
534
+ : undefined;
535
+ return okResult(result.data, meta);
536
+ }
417
537
  case 'fileNotFound':
418
538
  return errorResult(`Flow file not found: ${result.path}`);
419
539
  case 'badJson':
@@ -428,11 +548,11 @@ const buildTools = (ops: Operations): McpTool[] => [
428
548
  {
429
549
  name: 'seeflow_delete_flow',
430
550
  description: 'Unregister a flow from the studio (the on-disk file is left untouched).',
431
- inputSchema: FLOW_ID_INPUT_SCHEMA,
551
+ inputSchema: FLOW_PROJECT_INPUT_SCHEMA,
432
552
  handler: async (args) => {
433
- const v = requireFlowId(args);
553
+ const v = requireProjectFlow(args);
434
554
  if ('error' in v) return errorResult(v.error);
435
- const result = ops.deleteFlow(v.flowId);
555
+ const result = ops.deleteFlow(`${v.project}/${v.flow}`);
436
556
  switch (result.kind) {
437
557
  case 'ok':
438
558
  return okResult({ ok: true });
@@ -453,8 +573,13 @@ const buildTools = (ops: Operations): McpTool[] => [
453
573
  }
454
574
  const result = await ops.createProject(parsed.data);
455
575
  switch (result.kind) {
456
- case 'ok':
457
- return okResult(result.data);
576
+ case 'ok': {
577
+ const meta = canvasMetaFor(ctx, {
578
+ kind: 'create',
579
+ projectSlug: result.data.slug,
580
+ });
581
+ return okResult(result.data, meta);
582
+ }
458
583
  case 'alreadyExists':
459
584
  return errorResult(`Project already exists at ${result.path}`);
460
585
  case 'scaffoldFailed':
@@ -472,8 +597,8 @@ const buildTools = (ops: Operations): McpTool[] => [
472
597
  if (!parsed.success) {
473
598
  return errorResult(`Invalid add_node arguments: ${JSON.stringify(parsed.error.issues)}`);
474
599
  }
475
- const { flowId, node } = parsed.data;
476
- const result = await ops.addNode(flowId, node);
600
+ const { project, flow, node } = parsed.data;
601
+ const result = await ops.addNode(`${project}/${flow}`, node);
477
602
  switch (result.kind) {
478
603
  case 'ok':
479
604
  return okResult({ ok: true, id: result.data.id, node: result.data.node });
@@ -500,8 +625,8 @@ const buildTools = (ops: Operations): McpTool[] => [
500
625
  if (!parsed.success) {
501
626
  return errorResult(`Invalid add_bulk arguments: ${JSON.stringify(parsed.error.issues)}`);
502
627
  }
503
- const { flowId, nodes, connectors } = parsed.data;
504
- const result = await ops.addBulk(flowId, { nodes, connectors });
628
+ const { project, flow, nodes, connectors } = parsed.data;
629
+ const result = await ops.addBulk(`${project}/${flow}`, { nodes, connectors });
505
630
  switch (result.kind) {
506
631
  case 'ok':
507
632
  return okResult({
@@ -537,8 +662,8 @@ const buildTools = (ops: Operations): McpTool[] => [
537
662
  if (!parsed.success) {
538
663
  return errorResult(`Invalid delete_node arguments: ${JSON.stringify(parsed.error.issues)}`);
539
664
  }
540
- const { flowId, nodeId } = parsed.data;
541
- const result = await ops.deleteNode(flowId, nodeId);
665
+ const { project, flow, nodeId } = parsed.data;
666
+ const result = await ops.deleteNode(`${project}/${flow}`, nodeId);
542
667
  switch (result.kind) {
543
668
  case 'ok':
544
669
  return okResult({ ok: true });
@@ -566,8 +691,8 @@ const buildTools = (ops: Operations): McpTool[] => [
566
691
  if (!parsed.success) {
567
692
  return errorResult(`Invalid move_node arguments: ${JSON.stringify(parsed.error.issues)}`);
568
693
  }
569
- const { flowId, nodeId, x, y } = parsed.data;
570
- const result = await ops.moveNode(flowId, nodeId, { x, y });
694
+ const { project, flow, nodeId, x, y } = parsed.data;
695
+ const result = await ops.moveNode(`${project}/${flow}`, nodeId, { x, y });
571
696
  switch (result.kind) {
572
697
  case 'ok':
573
698
  return okResult({ ok: true, position: result.data.position });
@@ -596,8 +721,8 @@ const buildTools = (ops: Operations): McpTool[] => [
596
721
  if (!parsed.success) {
597
722
  return errorResult(`Invalid patch_node arguments: ${JSON.stringify(parsed.error.issues)}`);
598
723
  }
599
- const { flowId, nodeId, ...updates } = parsed.data;
600
- const result = await ops.patchNode(flowId, nodeId, updates);
724
+ const { project, flow, nodeId, ...updates } = parsed.data;
725
+ const result = await ops.patchNode(`${project}/${flow}`, nodeId, updates);
601
726
  switch (result.kind) {
602
727
  case 'ok':
603
728
  return okResult({ ok: true });
@@ -628,12 +753,12 @@ const buildTools = (ops: Operations): McpTool[] => [
628
753
  `Invalid reorder_node arguments: ${JSON.stringify(parsed.error.issues)}`,
629
754
  );
630
755
  }
631
- const { flowId, nodeId, ...body } = parsed.data;
756
+ const { project, flow, nodeId, ...body } = parsed.data;
632
757
  // Delegate the op-specific shape to the existing ReorderBodySchema so
633
758
  // reorderNodeImpl receives the same discriminated union the REST route
634
759
  // does — keeps a single source of truth for op semantics.
635
760
  const reorderBody = ReorderBodySchema.parse(body);
636
- const result = await ops.reorderNode(flowId, nodeId, reorderBody);
761
+ const result = await ops.reorderNode(`${project}/${flow}`, nodeId, reorderBody);
637
762
  switch (result.kind) {
638
763
  case 'ok':
639
764
  return okResult({ ok: true });
@@ -664,8 +789,8 @@ const buildTools = (ops: Operations): McpTool[] => [
664
789
  `Invalid add_connector arguments: ${JSON.stringify(parsed.error.issues)}`,
665
790
  );
666
791
  }
667
- const { flowId, connector } = parsed.data;
668
- const result = await ops.addConnector(flowId, connector);
792
+ const { project, flow, connector } = parsed.data;
793
+ const result = await ops.addConnector(`${project}/${flow}`, connector);
669
794
  switch (result.kind) {
670
795
  case 'ok':
671
796
  return okResult({ ok: true, id: result.data.id });
@@ -694,8 +819,8 @@ const buildTools = (ops: Operations): McpTool[] => [
694
819
  `Invalid patch_connector arguments: ${JSON.stringify(parsed.error.issues)}`,
695
820
  );
696
821
  }
697
- const { flowId, connectorId, ...updates } = parsed.data;
698
- const result = await ops.patchConnector(flowId, connectorId, updates);
822
+ const { project, flow, connectorId, ...updates } = parsed.data;
823
+ const result = await ops.patchConnector(`${project}/${flow}`, connectorId, updates);
699
824
  switch (result.kind) {
700
825
  case 'ok':
701
826
  return okResult({ ok: true });
@@ -725,8 +850,8 @@ const buildTools = (ops: Operations): McpTool[] => [
725
850
  `Invalid delete_connector arguments: ${JSON.stringify(parsed.error.issues)}`,
726
851
  );
727
852
  }
728
- const { flowId, connectorId } = parsed.data;
729
- const result = await ops.deleteConnector(flowId, connectorId);
853
+ const { project, flow, connectorId } = parsed.data;
854
+ const result = await ops.deleteConnector(`${project}/${flow}`, connectorId);
730
855
  switch (result.kind) {
731
856
  case 'ok':
732
857
  return okResult({ ok: true });
@@ -758,9 +883,16 @@ export function createMcpServer(options: CreateMcpServerOptions): Server {
758
883
  registry: options.registry,
759
884
  watcher: options.watcher,
760
885
  });
761
- const tools = buildTools(ops);
886
+ const tools = buildTools(ops, {
887
+ registry: options.registry,
888
+ token: options.token,
889
+ httpUrl: options.httpUrl,
890
+ });
762
891
 
763
- const server = new Server({ name: 'seeflow', version: '0.1.0' }, { capabilities: { tools: {} } });
892
+ const server = new Server(
893
+ { name: 'seeflow', version: '0.1.0' },
894
+ { capabilities: { tools: {}, resources: {} } },
895
+ );
764
896
 
765
897
  server.setRequestHandler(ListToolsRequestSchema, () => ({
766
898
  tools: tools.map(({ name, description, inputSchema }) => ({
@@ -781,5 +913,34 @@ export function createMcpServer(options: CreateMcpServerOptions): Server {
781
913
  return tool.handler(request.params.arguments);
782
914
  });
783
915
 
916
+ // MCP Apps resource: a single readable HTML bundle the host iframes when a
917
+ // canvas-bearing tool returns `_meta['openai/outputTemplate'] = CANVAS_RESOURCE_URI`.
918
+ // Listed unconditionally — the bundle is part of the binary, even on hosts
919
+ // that don't speak MCP Apps (they just ignore the resource).
920
+ server.setRequestHandler(ListResourcesRequestSchema, () => ({
921
+ resources: [
922
+ {
923
+ uri: CANVAS_RESOURCE_URI,
924
+ name: 'SeeFlow Canvas',
925
+ mimeType: CANVAS_RESOURCE_MIME,
926
+ },
927
+ ],
928
+ }));
929
+
930
+ server.setRequestHandler(ReadResourceRequestSchema, (request) => {
931
+ if (request.params.uri !== CANVAS_RESOURCE_URI) {
932
+ throw new Error(`Unknown resource: ${request.params.uri}`);
933
+ }
934
+ return {
935
+ contents: [
936
+ {
937
+ uri: CANVAS_RESOURCE_URI,
938
+ mimeType: CANVAS_RESOURCE_MIME,
939
+ text: readCanvasHtml(),
940
+ },
941
+ ],
942
+ };
943
+ });
944
+
784
945
  return server;
785
946
  }
package/src/node-files.ts CHANGED
@@ -3,9 +3,12 @@ import { dirname, join } from 'node:path';
3
3
  import { writeFileAtomic } from './atomic-write.ts';
4
4
 
5
5
  // Spec for fields that the studio externalizes to disk under
6
- // `<project>/nodes/<id>/<fileName>`. `nodeTypes` (when present) scopes the
7
- // spec entry to specific node types; absent means "applies to every node
8
- // type". Adding a future text field is one line.
6
+ // `<repoPath>/<flowDir>/nodes/<id>/<fileName>`, where `flowDir` is
7
+ // `dirname(entry.flowPath)`. For manifest-driven projects this resolves to
8
+ // `flows/<flow-id>/nodes/<id>/`; for legacy single-flow fixtures with
9
+ // `flowPath: 'flow.json'` it collapses to the project root. `nodeTypes` (when
10
+ // present) scopes the spec entry to specific node types; absent means
11
+ // "applies to every node type". Adding a future text field is one line.
9
12
  export interface ExternalizedFieldSpec {
10
13
  field: string;
11
14
  fileName: string;
@@ -26,6 +29,10 @@ export const externalizedFieldsForNodeType = (
26
29
 
27
30
  export type ExternalizedFieldName = (typeof EXTERNALIZED_NODE_FIELDS)[number]['field'];
28
31
 
32
+ // Flow-relative on-disk path under the flow folder. Returned with forward
33
+ // slashes so it round-trips through HTTP responses (the upload route ships
34
+ // this back as the `path` field, and the watcher's image-ref resolver treats
35
+ // it as relative to the flow folder).
29
36
  export const nodeFileRelPath = (nodeId: string, fileName: string): string =>
30
37
  `nodes/${nodeId}/${fileName}`;
31
38
 
@@ -35,14 +42,18 @@ export const nodeFileRelPath = (nodeId: string, fileName: string): string =>
35
42
  // that the file lives under the given node.
36
43
  export const nodeFileRef = (_nodeId: string, fileName: string): string => `file://${fileName}`;
37
44
 
38
- export const nodeFileAbsPath = (repoPath: string, nodeId: string, fileName: string): string =>
39
- join(repoPath, nodeFileRelPath(nodeId, fileName));
45
+ export const nodeFileAbsPath = (
46
+ repoPath: string,
47
+ flowDir: string,
48
+ nodeId: string,
49
+ fileName: string,
50
+ ): string => join(repoPath, flowDir, nodeFileRelPath(nodeId, fileName));
40
51
 
41
52
  export function writeNodeFile(absPath: string, content: string): void {
42
53
  mkdirSync(dirname(absPath), { recursive: true });
43
54
  writeFileAtomic(absPath, content);
44
55
  }
45
56
 
46
- export function removeNodeDir(repoPath: string, nodeId: string): void {
47
- rmSync(join(repoPath, 'nodes', nodeId), { recursive: true, force: true });
57
+ export function removeNodeDir(repoPath: string, flowDir: string, nodeId: string): void {
58
+ rmSync(join(repoPath, flowDir, 'nodes', nodeId), { recursive: true, force: true });
48
59
  }