@decocms/runtime 1.2.11 → 1.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.2.11",
3
+ "version": "1.2.12",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
package/src/index.ts CHANGED
@@ -28,7 +28,9 @@ export {
28
28
  type ResourceExecutionContext,
29
29
  type ResourceContents,
30
30
  type CreatedResource,
31
+ type WorkflowDefinition,
31
32
  } from "./tools.ts";
33
+ export { createWorkflow } from "./workflows.ts";
32
34
  import type { Binding } from "./wrangler.ts";
33
35
  export { proxyConnectionForId, BindingOf } from "./bindings.ts";
34
36
  export { type CORSOptions, type CORSOrigin } from "./cors.ts";
package/src/tools.ts CHANGED
@@ -17,10 +17,17 @@ import { BindingRegistry } from "./bindings.ts";
17
17
  import { Event, type EventHandlers } from "./events.ts";
18
18
  import type { DefaultEnv } from "./index.ts";
19
19
  import { State } from "./state.ts";
20
+ import {
21
+ type WorkflowDefinition,
22
+ Workflow,
23
+ WORKFLOW_SCOPES,
24
+ workflowToolId,
25
+ } from "./workflows.ts";
20
26
 
21
27
  // Re-export EventHandlers type and SELF constant for external use
22
28
  export { SELF } from "./events.ts";
23
29
  export type { EventHandlers } from "./events.ts";
30
+ export type { WorkflowDefinition } from "./workflows.ts";
24
31
 
25
32
  export const createRuntimeContext = (prev?: AppContext) => {
26
33
  const store = State.getStore();
@@ -42,13 +49,17 @@ export interface ToolExecutionContext<
42
49
 
43
50
  /**
44
51
  * Tool interface with generic schema types for type-safe tool creation.
52
+ *
53
+ * TId preserves the literal string type of `id` so consumers (e.g. the
54
+ * workflow builder) can derive union types of tool names without codegen.
45
55
  */
46
56
  export interface Tool<
47
57
  TSchemaIn extends ZodTypeAny = ZodTypeAny,
48
58
  TSchemaOut extends ZodTypeAny | undefined = undefined,
59
+ TId extends string = string,
49
60
  > {
50
61
  _meta?: Record<string, unknown>;
51
- id: string;
62
+ id: TId;
52
63
  description?: string;
53
64
  annotations?: ToolAnnotations;
54
65
  inputSchema: TSchemaIn;
@@ -246,7 +257,8 @@ export function createStreamableTool<TSchemaIn extends ZodSchema = ZodSchema>(
246
257
  export function createTool<
247
258
  TSchemaIn extends ZodSchema = ZodSchema,
248
259
  TSchemaOut extends ZodSchema | undefined = undefined,
249
- >(opts: Tool<TSchemaIn, TSchemaOut>): Tool<TSchemaIn, TSchemaOut> {
260
+ TId extends string = string,
261
+ >(opts: Tool<TSchemaIn, TSchemaOut, TId>): Tool<TSchemaIn, TSchemaOut, TId> {
250
262
  return {
251
263
  ...opts,
252
264
  execute: (input: ToolExecutionContext<TSchemaIn>) => {
@@ -517,6 +529,9 @@ export interface CreateMCPServerOptions<
517
529
  | Promise<CreatedResource[]>
518
530
  >
519
531
  | ((env: TEnv) => CreatedResource[] | Promise<CreatedResource[]>);
532
+ workflows?:
533
+ | WorkflowDefinition[]
534
+ | ((env: TEnv) => WorkflowDefinition[] | Promise<WorkflowDefinition[]>);
520
535
  }
521
536
 
522
537
  export type Fetch<TEnv = unknown> = (
@@ -541,16 +556,34 @@ const getEventBus = (
541
556
  : env?.MESH_REQUEST_CONTEXT?.state?.[prop];
542
557
  };
543
558
 
559
+ // TEnv is erased here because toolsFor() only reads events/workflows/configuration
560
+ // and doesn't need the full env type. Replacing `any` with a proper generic
561
+ // would require threading TEnv through toolsFor, which is a larger refactor.
562
+ type ResolvedMCPServerOptions<TSchema extends ZodTypeAny = never> = Omit<
563
+ CreateMCPServerOptions<any, TSchema>, // eslint-disable-line @typescript-eslint/no-explicit-any
564
+ "workflows"
565
+ > & { workflows?: WorkflowDefinition[] };
566
+
567
+ const getMeshCtx = (input: { runtimeContext: AppContext }) => {
568
+ const ctx = input.runtimeContext.env.MESH_REQUEST_CONTEXT;
569
+ return {
570
+ connectionId: ctx?.connectionId,
571
+ meshUrl: ctx?.meshUrl,
572
+ token: ctx?.token,
573
+ };
574
+ };
575
+
544
576
  const toolsFor = <TSchema extends ZodTypeAny = never>({
545
577
  events,
578
+ workflows,
546
579
  configuration: { state: schema, scopes, onChange } = {},
547
- }: CreateMCPServerOptions<any, TSchema> = {}): CreatedTool[] => {
580
+ }: ResolvedMCPServerOptions<TSchema> = {}): CreatedTool[] => {
548
581
  const jsonSchema = schema
549
582
  ? z.toJSONSchema(schema)
550
583
  : { type: "object", properties: {} };
551
584
  const busProp = String(events?.bus ?? "EVENT_BUS");
552
585
  return [
553
- ...(onChange || events
586
+ ...(onChange || events || workflows?.length
554
587
  ? [
555
588
  createTool({
556
589
  id: "ON_MCP_CONFIGURATION",
@@ -573,9 +606,7 @@ const toolsFor = <TSchema extends ZodTypeAny = never>({
573
606
  });
574
607
  const bus = getEventBus(busProp, input.runtimeContext.env);
575
608
  if (events && state && bus) {
576
- // Get connectionId for SELF subscriptions
577
- const connectionId =
578
- input.runtimeContext.env.MESH_REQUEST_CONTEXT?.connectionId;
609
+ const { connectionId } = getMeshCtx(input);
579
610
  // Sync subscriptions - always call to handle deletions too
580
611
  const subscriptions = Event.subscriptions(
581
612
  events?.handlers ?? ({} as Record<string, never>),
@@ -607,6 +638,23 @@ const toolsFor = <TSchema extends ZodTypeAny = never>({
607
638
  );
608
639
  }
609
640
  }
641
+
642
+ if (workflows?.length) {
643
+ const {
644
+ connectionId: wfConnectionId,
645
+ meshUrl,
646
+ token,
647
+ } = getMeshCtx(input);
648
+ if (wfConnectionId && meshUrl) {
649
+ await Workflow.sync(
650
+ workflows,
651
+ meshUrl,
652
+ wfConnectionId,
653
+ token,
654
+ );
655
+ }
656
+ }
657
+
610
658
  return Promise.resolve({});
611
659
  },
612
660
  }),
@@ -623,10 +671,8 @@ const toolsFor = <TSchema extends ZodTypeAny = never>({
623
671
  outputSchema: OnEventsOutputSchema,
624
672
  execute: async (input) => {
625
673
  const env = input.runtimeContext.env;
626
- // Get state from MESH_REQUEST_CONTEXT - this has the binding values
627
674
  const state = env.MESH_REQUEST_CONTEXT?.state as z.infer<TSchema>;
628
- // Get connectionId for SELF handlers
629
- const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId;
675
+ const { connectionId } = getMeshCtx(input);
630
676
  return Event.execute(
631
677
  events.handlers!,
632
678
  input.context.events,
@@ -652,10 +698,90 @@ const toolsFor = <TSchema extends ZodTypeAny = never>({
652
698
  scopes: [
653
699
  ...((scopes as string[]) ?? []),
654
700
  ...(events ? [`${busProp}::EVENT_SYNC_SUBSCRIPTIONS`] : []),
701
+ ...(workflows?.length ? [...WORKFLOW_SCOPES] : []),
655
702
  ],
656
703
  });
657
704
  },
658
705
  }),
706
+
707
+ // Auto-generated trigger tool for each declared workflow.
708
+ // Calls COLLECTION_WORKFLOW_EXECUTION_CREATE on the mesh and returns the
709
+ // execution ID immediately (fire-and-forget; poll with
710
+ // COLLECTION_WORKFLOW_EXECUTION_GET to track progress).
711
+ ...(workflows?.length
712
+ ? workflows.map((wf) => {
713
+ const id = wf.toolId ?? workflowToolId(wf.title);
714
+ return createTool({
715
+ id,
716
+ description: [
717
+ wf.description
718
+ ? `Run workflow: ${wf.description}`
719
+ : `Start the "${wf.title}" workflow.`,
720
+ "Returns an execution_id immediately. Use COLLECTION_WORKFLOW_EXECUTION_GET to track progress.",
721
+ ].join(" "),
722
+ inputSchema: z.object({
723
+ input: z
724
+ .record(z.string(), z.unknown())
725
+ .optional()
726
+ .describe(
727
+ "Input data for the workflow. Steps reference these values via @input.field.",
728
+ ),
729
+ virtual_mcp_id: z
730
+ .string()
731
+ .optional()
732
+ .describe(
733
+ wf.virtual_mcp_id
734
+ ? `Virtual MCP ID to use for execution (defaults to "${wf.virtual_mcp_id}").`
735
+ : "Virtual MCP ID that will execute the workflow steps.",
736
+ ),
737
+ start_at_epoch_ms: z
738
+ .number()
739
+ .int()
740
+ .min(0)
741
+ .optional()
742
+ .describe(
743
+ "Unix timestamp (ms) for scheduled execution. Omit to start immediately.",
744
+ ),
745
+ }),
746
+ outputSchema: z.object({
747
+ execution_id: z
748
+ .string()
749
+ .describe("ID of the created workflow execution."),
750
+ }),
751
+ execute: async (input) => {
752
+ const { connectionId, meshUrl, token } = getMeshCtx(input);
753
+
754
+ if (!connectionId || !meshUrl) {
755
+ throw new Error(
756
+ `[${id}] Missing MESH_REQUEST_CONTEXT (connectionId or meshUrl).`,
757
+ );
758
+ }
759
+
760
+ const ctx = input.context as {
761
+ input?: Record<string, unknown>;
762
+ virtual_mcp_id?: string;
763
+ start_at_epoch_ms?: number;
764
+ };
765
+
766
+ const virtualMcpId = ctx.virtual_mcp_id ?? wf.virtual_mcp_id;
767
+
768
+ const collectionId = Workflow.workflowId(connectionId, wf.title);
769
+ const executionId = await Workflow.createExecution(
770
+ meshUrl,
771
+ token,
772
+ {
773
+ workflow_collection_id: collectionId,
774
+ virtual_mcp_id: virtualMcpId,
775
+ input: ctx.input,
776
+ start_at_epoch_ms: ctx.start_at_epoch_ms,
777
+ },
778
+ );
779
+
780
+ return { execution_id: executionId };
781
+ },
782
+ });
783
+ })
784
+ : []),
659
785
  ];
660
786
  };
661
787
 
@@ -710,7 +836,14 @@ export const createMCPServer = <
710
836
  };
711
837
  const tools = await toolsFn(bindings);
712
838
 
713
- tools.push(...toolsFor<TSchema>(options));
839
+ const resolvedWorkflows =
840
+ typeof options.workflows === "function"
841
+ ? await options.workflows(bindings)
842
+ : options.workflows;
843
+
844
+ tools.push(
845
+ ...toolsFor<TSchema>({ ...options, workflows: resolvedWorkflows }),
846
+ );
714
847
 
715
848
  for (const tool of tools) {
716
849
  server.registerTool(
@@ -858,6 +991,9 @@ export const createMCPServer = <
858
991
  }
859
992
 
860
993
  // MCP SDK expects either text or blob content, not both
994
+ const meta =
995
+ (result as { _meta?: Record<string, unknown> | null })._meta ??
996
+ undefined;
861
997
  if (result.text !== undefined) {
862
998
  return {
863
999
  contents: [
@@ -865,6 +1001,7 @@ export const createMCPServer = <
865
1001
  uri: result.uri,
866
1002
  mimeType: result.mimeType,
867
1003
  text: result.text,
1004
+ ...(meta !== undefined ? { _meta: meta } : {}),
868
1005
  },
869
1006
  ],
870
1007
  };
@@ -875,6 +1012,7 @@ export const createMCPServer = <
875
1012
  uri: result.uri,
876
1013
  mimeType: result.mimeType,
877
1014
  blob: result.blob,
1015
+ ...(meta !== undefined ? { _meta: meta } : {}),
878
1016
  },
879
1017
  ],
880
1018
  };
@@ -0,0 +1,770 @@
1
+ import type { Step } from "@decocms/bindings/workflow";
2
+ import { z, type ZodTypeAny } from "zod";
3
+ import { proxyConnectionForId } from "./bindings.ts";
4
+ import { MCPClient } from "./mcp.ts";
5
+
6
+ /**
7
+ * Declarative workflow definition for MCP servers.
8
+ * Workflows declared here are automatically synced to the mesh
9
+ * as workflow_collection entries during ON_MCP_CONFIGURATION, and a
10
+ * trigger tool is automatically generated for each one.
11
+ */
12
+ export interface WorkflowDefinition {
13
+ title: string;
14
+ description?: string;
15
+ /**
16
+ * Virtual MCP ID that will execute this workflow's steps.
17
+ * Used as the default for the generated trigger tool.
18
+ * Can be overridden at call time via the tool's `virtual_mcp_id` input.
19
+ */
20
+ virtual_mcp_id?: string;
21
+ steps: Step[];
22
+ /**
23
+ * Override the auto-generated tool ID for the workflow trigger tool.
24
+ * Defaults to START_WORKFLOW_<TITLE_SLUG> (e.g. START_WORKFLOW_FETCH_USERS).
25
+ */
26
+ toolId?: string;
27
+ }
28
+
29
+ interface WorkflowCollectionItem {
30
+ id: string;
31
+ title: string;
32
+ description: string | null;
33
+ virtual_mcp_id: string | null;
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+
38
+ interface DefaultVirtualMCPItem {
39
+ id: string;
40
+ title: string;
41
+ }
42
+
43
+ /**
44
+ * Hand-rolled client interface for the workflow collection tools exposed by
45
+ * the mesh's /mcp/self endpoint.
46
+ *
47
+ * TODO: Replace with a generated client derived from WorkflowBinding in
48
+ * @decocms/bindings/workflow once that binding covers write operations
49
+ * (CREATE, UPDATE, DELETE) and COLLECTION_WORKFLOW_EXECUTION_CREATE.
50
+ * Until then, any rename of a tool or field on the server side requires a
51
+ * matching change here — the bindings system was designed to prevent exactly
52
+ * this class of silent drift.
53
+ */
54
+ interface MeshWorkflowClient {
55
+ COLLECTION_WORKFLOW_LIST: (input: {
56
+ limit?: number;
57
+ offset?: number;
58
+ }) => Promise<{
59
+ items: WorkflowCollectionItem[];
60
+ totalCount: number;
61
+ hasMore: boolean;
62
+ }>;
63
+ COLLECTION_WORKFLOW_CREATE: (input: {
64
+ data: {
65
+ id: string;
66
+ title: string;
67
+ description?: string;
68
+ virtual_mcp_id?: string;
69
+ steps: Step[];
70
+ };
71
+ }) => Promise<{ item: WorkflowCollectionItem }>;
72
+ COLLECTION_WORKFLOW_UPDATE: (input: {
73
+ id: string;
74
+ data: {
75
+ title?: string;
76
+ description?: string;
77
+ virtual_mcp_id?: string;
78
+ steps?: Step[];
79
+ };
80
+ }) => Promise<{ success: boolean; error?: string }>;
81
+ COLLECTION_WORKFLOW_DELETE: (input: {
82
+ id: string;
83
+ }) => Promise<{ success: boolean; error?: string }>;
84
+ COLLECTION_WORKFLOW_EXECUTION_CREATE: (input: {
85
+ workflow_collection_id: string;
86
+ virtual_mcp_id?: string;
87
+ input?: Record<string, unknown>;
88
+ start_at_epoch_ms?: number;
89
+ }) => Promise<{ item: { id: string } }>;
90
+ COLLECTION_VIRTUAL_MCP_LIST: (input: {
91
+ where?: {
92
+ operator: "and";
93
+ conditions: Array<{ field: string[]; operator: string; value: unknown }>;
94
+ };
95
+ limit?: number;
96
+ offset?: number;
97
+ }) => Promise<{
98
+ items: DefaultVirtualMCPItem[];
99
+ totalCount: number;
100
+ hasMore: boolean;
101
+ }>;
102
+ COLLECTION_VIRTUAL_MCP_CREATE: (input: {
103
+ data: {
104
+ title: string;
105
+ connections: Array<{
106
+ connection_id: string;
107
+ selected_tools: null;
108
+ }>;
109
+ };
110
+ }) => Promise<{ item: DefaultVirtualMCPItem }>;
111
+ }
112
+
113
+ function slugify(title: string): string {
114
+ return title
115
+ .toLowerCase()
116
+ .trim()
117
+ .replace(/[^a-z0-9]+/g, "-")
118
+ .replace(/^-|-$/g, "");
119
+ }
120
+
121
+ function workflowId(connectionId: string, title: string): string {
122
+ return `${connectionId}::${slugify(title)}`;
123
+ }
124
+
125
+ /**
126
+ * Derives the auto-generated trigger tool ID for a workflow.
127
+ * "Fetch Users and Process" → "START_WORKFLOW_FETCH_USERS_AND_PROCESS"
128
+ */
129
+ export function workflowToolId(title: string): string {
130
+ return `START_WORKFLOW_${slugify(title).toUpperCase().replace(/-/g, "_")}`;
131
+ }
132
+
133
+ function createMeshSelfClient(
134
+ meshUrl: string,
135
+ token?: string,
136
+ ): MeshWorkflowClient {
137
+ const connection = proxyConnectionForId("self", { meshUrl, token });
138
+ return MCPClient.forConnection(connection) as unknown as MeshWorkflowClient;
139
+ }
140
+
141
+ // I7: Per-connectionId mutex — chains incoming syncs so operations never interleave.
142
+ const syncInFlight = new Map<string, Promise<void>>();
143
+
144
+ // I4: Fingerprint of the last successfully synced declared set, keyed by connectionId.
145
+ // Capped at MAX_FINGERPRINT_CACHE entries to prevent unbounded growth in environments
146
+ // with rotating connection IDs. Oldest entry is evicted when the cap is reached.
147
+ // Callers that own connection lifecycle should call Workflow.clearFingerprint() on teardown.
148
+ const MAX_FINGERPRINT_CACHE = 500;
149
+ const workflowFingerprints = new Map<string, string>();
150
+
151
+ function setFingerprint(connectionId: string, fingerprint: string) {
152
+ if (
153
+ !workflowFingerprints.has(connectionId) &&
154
+ workflowFingerprints.size >= MAX_FINGERPRINT_CACHE
155
+ ) {
156
+ const firstKey = workflowFingerprints.keys().next().value;
157
+ if (firstKey !== undefined) workflowFingerprints.delete(firstKey);
158
+ }
159
+ workflowFingerprints.set(connectionId, fingerprint);
160
+ }
161
+
162
+ // Derives the title for the auto-generated default Virtual MCP.
163
+ // Embedding the connectionId makes each VMCP identifiable in the UI and
164
+ // uniquely addressable in LIST lookups without relying on the connection_id
165
+ // filter alone (which would match any VMCP that includes this connection).
166
+ function defaultVmcpTitle(connectionId: string): string {
167
+ return `Workflows Agent (${connectionId})`;
168
+ }
169
+
170
+ // Cache of connectionId → auto-created default Virtual MCP ID.
171
+ // Capped at the same size as the fingerprint cache.
172
+ const defaultVmcpByConnection = new Map<string, string>();
173
+
174
+ function setDefaultVmcp(connectionId: string, vmcpId: string) {
175
+ if (
176
+ !defaultVmcpByConnection.has(connectionId) &&
177
+ defaultVmcpByConnection.size >= MAX_FINGERPRINT_CACHE
178
+ ) {
179
+ const firstKey = defaultVmcpByConnection.keys().next().value;
180
+ if (firstKey !== undefined) defaultVmcpByConnection.delete(firstKey);
181
+ }
182
+ defaultVmcpByConnection.set(connectionId, vmcpId);
183
+ }
184
+
185
+ /**
186
+ * Returns the ID of the "Workflows Agent" Virtual MCP for a connection,
187
+ * creating one if it does not yet exist.
188
+ *
189
+ * Resolution order:
190
+ * 1. Module-level cache (avoids the round-trip within a process lifetime).
191
+ * 2. Remote LIST filtered by connection_id + title (survives restarts).
192
+ * 3. Remote CREATE — only when no matching VMCP is found.
193
+ *
194
+ * Any network failure is logged and causes the function to return undefined
195
+ * so callers continue without a default rather than failing the whole sync.
196
+ */
197
+ async function resolveDefaultVirtualMcp(
198
+ connectionId: string,
199
+ client: MeshWorkflowClient,
200
+ tag: string,
201
+ ): Promise<string | undefined> {
202
+ const cached = defaultVmcpByConnection.get(connectionId);
203
+ if (cached) {
204
+ console.log(`${tag} Using cached default Virtual MCP: ${cached}`);
205
+ return cached;
206
+ }
207
+
208
+ const title = defaultVmcpTitle(connectionId);
209
+
210
+ try {
211
+ const result = await client.COLLECTION_VIRTUAL_MCP_LIST({
212
+ where: {
213
+ operator: "and",
214
+ conditions: [
215
+ { field: ["connection_id"], operator: "eq", value: connectionId },
216
+ { field: ["title"], operator: "eq", value: title },
217
+ ],
218
+ },
219
+ limit: 1,
220
+ });
221
+ if (result.items.length > 0) {
222
+ const vmcpId = result.items[0]!.id;
223
+ setDefaultVmcp(connectionId, vmcpId);
224
+ console.log(`${tag} Found existing default Virtual MCP: ${vmcpId}`);
225
+ return vmcpId;
226
+ }
227
+ } catch (err) {
228
+ console.warn(
229
+ `${tag} Could not list Virtual MCPs — proceeding without default. Error: ${err instanceof Error ? err.message : String(err)}`,
230
+ );
231
+ return undefined;
232
+ }
233
+
234
+ try {
235
+ const created = await client.COLLECTION_VIRTUAL_MCP_CREATE({
236
+ data: {
237
+ title,
238
+ connections: [{ connection_id: connectionId, selected_tools: null }],
239
+ },
240
+ });
241
+ const vmcpId = created.item.id;
242
+ setDefaultVmcp(connectionId, vmcpId);
243
+ console.log(`${tag} Created default Virtual MCP: ${vmcpId}`);
244
+ return vmcpId;
245
+ } catch (err) {
246
+ console.warn(
247
+ `${tag} Could not create default Virtual MCP — proceeding without default. Error: ${err instanceof Error ? err.message : String(err)}`,
248
+ );
249
+ return undefined;
250
+ }
251
+ }
252
+
253
+ function fingerprintWorkflows(declared: WorkflowDefinition[]): string {
254
+ return JSON.stringify(
255
+ declared.map((w) => ({
256
+ title: w.title,
257
+ description: w.description ?? null,
258
+ virtual_mcp_id: w.virtual_mcp_id ?? null,
259
+ steps: w.steps,
260
+ toolId: w.toolId ?? null,
261
+ })),
262
+ );
263
+ }
264
+
265
+ async function doSyncWorkflows(
266
+ declared: WorkflowDefinition[],
267
+ meshUrl: string,
268
+ connectionId: string,
269
+ token?: string,
270
+ _clientOverride?: MeshWorkflowClient,
271
+ ): Promise<void> {
272
+ const tag = `[Workflows][${connectionId}]`;
273
+
274
+ // I6: Reject any title that slugifies to empty — would produce IDs like "conn_abc::".
275
+ const emptySlugWf = declared.find((w) => slugify(w.title) === "");
276
+ if (emptySlugWf !== undefined) {
277
+ console.warn(
278
+ `${tag} Workflow title "${emptySlugWf.title}" produces an empty ID. Skipping sync.`,
279
+ );
280
+ return;
281
+ }
282
+
283
+ if (declared.length > 0) {
284
+ const slugs = declared.map((w) => slugify(w.title));
285
+ const uniqueSlugs = new Set(slugs);
286
+ if (uniqueSlugs.size !== slugs.length) {
287
+ const duplicateSlugs = new Set(
288
+ slugs.filter((s, i) => slugs.indexOf(s) !== i),
289
+ );
290
+ const collidingTitles = declared
291
+ .filter((w) => duplicateSlugs.has(slugify(w.title)))
292
+ .map((w) => w.title);
293
+ console.warn(
294
+ `${tag} Workflow titles that produce duplicate IDs: ${[...new Set(collidingTitles)].join(", ")}. Skipping sync.`,
295
+ );
296
+ return;
297
+ }
298
+ }
299
+
300
+ // I4: Skip the remote round-trip when the declared set is identical to the last sync.
301
+ const fingerprint = fingerprintWorkflows(declared);
302
+ const storedFingerprint = workflowFingerprints.get(connectionId);
303
+ if (storedFingerprint === fingerprint) {
304
+ console.log(
305
+ `${tag} Fingerprint unchanged — skipping sync. Declared: ${declared.length} workflow(s): [${declared.map((w) => w.title).join(", ")}]`,
306
+ );
307
+ return;
308
+ }
309
+ console.log(
310
+ `${tag} Fingerprint changed (or first sync) — starting sync. Declared: ${declared.length} workflow(s): [${declared.map((w) => w.title).join(", ")}]`,
311
+ storedFingerprint
312
+ ? "(previous fingerprint existed)"
313
+ : "(no previous fingerprint)",
314
+ );
315
+
316
+ const client = _clientOverride ?? createMeshSelfClient(meshUrl, token);
317
+
318
+ // Only resolve (or lazily create) the default Virtual MCP when at least one
319
+ // declared workflow actually needs the fallback `virtual_mcp_id`.
320
+ const needsDefault = declared.some((w) => w.virtual_mcp_id === undefined);
321
+ const defaultVmcpId = needsDefault
322
+ ? await resolveDefaultVirtualMcp(connectionId, client, tag)
323
+ : undefined;
324
+
325
+ let existing: WorkflowCollectionItem[];
326
+ try {
327
+ const allItems: WorkflowCollectionItem[] = [];
328
+ let offset = 0;
329
+ const limit = 200;
330
+ while (true) {
331
+ const page = await client.COLLECTION_WORKFLOW_LIST({ limit, offset });
332
+ allItems.push(...page.items);
333
+ if (!page.hasMore || page.items.length === 0) break;
334
+ offset += page.items.length;
335
+ }
336
+ existing = allItems;
337
+ console.log(
338
+ `${tag} LIST returned ${existing.length} total workflow(s). IDs owned by this connection: [${
339
+ existing
340
+ .filter((w) => w.id.startsWith(`${connectionId}::`))
341
+ .map((w) => w.id)
342
+ .join(", ") || "none"
343
+ }]`,
344
+ );
345
+ } catch (err) {
346
+ const errMsg = err instanceof Error ? err.message : String(err);
347
+ console.warn(
348
+ `${tag} Could not list workflows (workflows plugin may not be enabled). Skipping sync. Error: ${errMsg}`,
349
+ );
350
+ return;
351
+ }
352
+
353
+ const prefix = `${connectionId}::`;
354
+ const managed = new Map(
355
+ existing.filter((w) => w.id.startsWith(prefix)).map((w) => [w.id, w]),
356
+ );
357
+
358
+ // I5: Build ID→definition map synchronously so declaredIds is ready before
359
+ // parallelizing — the orphan-delete pass needs the complete set upfront.
360
+ const declaredEntries = declared.map(
361
+ (wf) => [workflowId(connectionId, wf.title), wf] as const,
362
+ );
363
+ const declaredIds = new Set(declaredEntries.map(([id]) => id));
364
+
365
+ let hadError = false;
366
+
367
+ // I5: Upserts run in parallel — no ordering dependency between workflows.
368
+ await Promise.all(
369
+ declaredEntries.map(async ([id, wf]) => {
370
+ const op = managed.has(id) ? "UPDATE" : "CREATE";
371
+ console.log(`${tag} ${op} "${wf.title}" (id=${id})`);
372
+ try {
373
+ // Explicit declaration wins; fall back to the auto-resolved default.
374
+ const resolvedVmcpId = wf.virtual_mcp_id ?? defaultVmcpId;
375
+
376
+ if (op === "UPDATE") {
377
+ const result = await client.COLLECTION_WORKFLOW_UPDATE({
378
+ id,
379
+ data: {
380
+ title: wf.title,
381
+ description: wf.description,
382
+ ...(resolvedVmcpId !== undefined && {
383
+ virtual_mcp_id: resolvedVmcpId,
384
+ }),
385
+ steps: wf.steps,
386
+ },
387
+ });
388
+ if (!result.success) {
389
+ hadError = true;
390
+ console.warn(
391
+ `${tag} UPDATE "${wf.title}" returned success=false:`,
392
+ String(result.error ?? "(no error message)"),
393
+ );
394
+ } else {
395
+ console.log(`${tag} UPDATE "${wf.title}" OK`);
396
+ }
397
+ } else {
398
+ await client.COLLECTION_WORKFLOW_CREATE({
399
+ data: {
400
+ id,
401
+ title: wf.title,
402
+ description: wf.description,
403
+ virtual_mcp_id: resolvedVmcpId,
404
+ steps: wf.steps,
405
+ },
406
+ });
407
+ console.log(`${tag} CREATE "${wf.title}" OK`);
408
+ }
409
+ } catch (error) {
410
+ hadError = true;
411
+ console.warn(
412
+ `${tag} Failed to ${op} workflow "${wf.title}":`,
413
+ error instanceof Error ? error.message : String(error),
414
+ );
415
+ }
416
+ }),
417
+ );
418
+
419
+ // I5: Deletes run in parallel — orphans are independent of each other.
420
+ const orphanIds = [...managed.keys()].filter((id) => !declaredIds.has(id));
421
+ if (orphanIds.length > 0) {
422
+ console.log(
423
+ `${tag} Deleting ${orphanIds.length} orphaned workflow(s): [${orphanIds.join(", ")}]`,
424
+ );
425
+ }
426
+ await Promise.all(
427
+ orphanIds.map(async (id) => {
428
+ try {
429
+ await client.COLLECTION_WORKFLOW_DELETE({ id });
430
+ console.log(`${tag} DELETE "${id}" OK`);
431
+ } catch (error) {
432
+ hadError = true;
433
+ console.warn(
434
+ `${tag} Failed to delete orphaned workflow "${id}":`,
435
+ error instanceof Error ? error.message : String(error),
436
+ );
437
+ }
438
+ }),
439
+ );
440
+
441
+ // I4: Only record the fingerprint when every operation succeeded so that
442
+ // a follow-up call with an identical declared set retries any failures
443
+ // rather than silently skipping them.
444
+ if (!hadError) {
445
+ setFingerprint(connectionId, fingerprint);
446
+ console.log(`${tag} Sync complete — fingerprint stored.`);
447
+ } else {
448
+ console.warn(
449
+ `${tag} Sync finished with errors — fingerprint NOT stored so the next call will retry.`,
450
+ );
451
+ }
452
+ }
453
+
454
+ async function syncWorkflows(
455
+ declared: WorkflowDefinition[],
456
+ meshUrl: string,
457
+ connectionId: string,
458
+ token?: string,
459
+ /**
460
+ * @internal Only used in tests to capture payloads without a real server.
461
+ * Not part of the public API contract; may be removed without notice.
462
+ */
463
+ _clientOverride?: MeshWorkflowClient,
464
+ ): Promise<void> {
465
+ // I7: Chain onto any in-flight sync for this connectionId so concurrent calls
466
+ // never interleave LIST/CREATE/DELETE operations against the same connection.
467
+ const previous = syncInFlight.get(connectionId) ?? Promise.resolve();
468
+ const next = previous
469
+ // Isolate from predecessor's rejection so a failed prior sync doesn't
470
+ // propagate its error to unrelated callers queued behind it.
471
+ .catch(() => {})
472
+ .then(() =>
473
+ doSyncWorkflows(declared, meshUrl, connectionId, token, _clientOverride),
474
+ )
475
+ .finally(() => {
476
+ if (syncInFlight.get(connectionId) === next) {
477
+ syncInFlight.delete(connectionId);
478
+ }
479
+ });
480
+ syncInFlight.set(connectionId, next);
481
+ return next;
482
+ }
483
+
484
+ /**
485
+ * Scopes required by a connection that declares workflows.
486
+ * Co-located here so any rename of a server-side tool name causes a compile
487
+ * error in the consumer rather than a silent scope mismatch.
488
+ */
489
+ export const WORKFLOW_SCOPES = [
490
+ "SELF::COLLECTION_WORKFLOW_LIST",
491
+ "SELF::COLLECTION_WORKFLOW_CREATE",
492
+ "SELF::COLLECTION_WORKFLOW_UPDATE",
493
+ "SELF::COLLECTION_WORKFLOW_DELETE",
494
+ "SELF::COLLECTION_WORKFLOW_EXECUTION_CREATE",
495
+ "SELF::COLLECTION_VIRTUAL_MCP_LIST",
496
+ "SELF::COLLECTION_VIRTUAL_MCP_CREATE",
497
+ ] as const;
498
+
499
+ export const Workflow = {
500
+ sync: syncWorkflows,
501
+ slugify,
502
+ workflowId,
503
+ toolId: workflowToolId,
504
+ /**
505
+ * Creates a workflow execution via the mesh self-endpoint.
506
+ * Returns the execution ID of the newly created execution.
507
+ * This keeps the MeshWorkflowClient factory internal to this module.
508
+ */
509
+ createExecution: async (
510
+ meshUrl: string,
511
+ token: string | undefined,
512
+ params: {
513
+ workflow_collection_id: string;
514
+ virtual_mcp_id?: string;
515
+ input?: Record<string, unknown>;
516
+ start_at_epoch_ms?: number;
517
+ },
518
+ ): Promise<string> => {
519
+ const client = createMeshSelfClient(meshUrl, token);
520
+ const result = await client.COLLECTION_WORKFLOW_EXECUTION_CREATE(params);
521
+ return result.item.id;
522
+ },
523
+ /**
524
+ * Clears the cached fingerprint and default Virtual MCP ID for a connection
525
+ * so the next sync performs a full remote round-trip. Call this on connection
526
+ * teardown or when you need to force a re-sync without changing the declared
527
+ * workflow set.
528
+ */
529
+ clearFingerprint: (connectionId: string) => {
530
+ workflowFingerprints.delete(connectionId);
531
+ defaultVmcpByConnection.delete(connectionId);
532
+ },
533
+ };
534
+
535
+ // ============================================================================
536
+ // Fluent Workflow Builder
537
+ // ============================================================================
538
+
539
+ /**
540
+ * Minimal tool shape for builder type inference and schema injection.
541
+ * Defined locally to avoid a circular import with tools.ts (which imports
542
+ * WorkflowDefinition from this file). Includes inputSchema so the builder
543
+ * can derive per-tool input key suggestions, and outputSchema so the builder
544
+ * can auto-inject a step's outputSchema to detect schema drift at sync time.
545
+ */
546
+ type ToolLike<TId extends string = string> = {
547
+ id: TId;
548
+ inputSchema: ZodTypeAny;
549
+ outputSchema?: ZodTypeAny;
550
+ };
551
+
552
+ /**
553
+ * All valid @ref strings given the set of declared step names TSteps.
554
+ *
555
+ * - `@input` / `@input.field` — workflow input
556
+ * - `@stepName` / `@stepName.field` — output of a declared step
557
+ * - `@item` / `@item.${string}` — current forEach item
558
+ * - `@index` — current forEach index
559
+ * - `@ctx.execution_id` — current execution ID
560
+ *
561
+ * `string & {}` keeps the type as `string` so arbitrary values still compile,
562
+ * but the union members get autocomplete and type-narrowing in editors.
563
+ */
564
+ type KnownRefs<TSteps extends string> =
565
+ | `@input`
566
+ | `@input.${string}`
567
+ | `@item`
568
+ | `@item.${string}`
569
+ | `@index`
570
+ | `@ctx.execution_id`
571
+ | `@${TSteps}`
572
+ | `@${TSteps}.${string}`
573
+ | (string & {});
574
+
575
+ type StepInput<TSteps extends string> = Record<
576
+ string,
577
+ KnownRefs<TSteps> | unknown
578
+ >;
579
+
580
+ /**
581
+ * Derives the `input` type for a tool step.
582
+ *
583
+ * Keys are the tool's inputSchema field names (for autocomplete); values are
584
+ * @refs or any literal. An index signature allows additional arbitrary keys.
585
+ * Falls back to generic StepInput when the tool is not found in TTools.
586
+ */
587
+ type InputForTool<
588
+ TTools extends readonly ToolLike[],
589
+ TId extends string,
590
+ TSteps extends string,
591
+ > = Extract<TTools[number], { id: TId }> extends { inputSchema: infer TIn }
592
+ ? TIn extends ZodTypeAny
593
+ ? {
594
+ [K in keyof TIn["_output"]]?: KnownRefs<TSteps> | TIn["_output"][K];
595
+ } & { [key: string]: KnownRefs<TSteps> | unknown }
596
+ : StepInput<TSteps>
597
+ : StepInput<TSteps>;
598
+
599
+ type BaseStepFields = Omit<Step, "name" | "input" | "action">;
600
+ type BaseForEachFields = Omit<Step, "name" | "forEach" | "input" | "action">;
601
+
602
+ /**
603
+ * Tool-call variants of StepOpts — one discriminated member per tool ID so
604
+ * TypeScript narrows the `input` type based on the value of `toolName`.
605
+ * Falls back to `string` for toolName when no tools are registered.
606
+ */
607
+ type ToolCallStepOpts<
608
+ TSteps extends string,
609
+ TTools extends readonly ToolLike[],
610
+ > = [TTools[number]] extends [never]
611
+ ? BaseStepFields & {
612
+ action: { toolName: string & {}; transformCode?: string };
613
+ input?: StepInput<TSteps>;
614
+ }
615
+ : {
616
+ [TId in TTools[number]["id"]]: BaseStepFields & {
617
+ action: { toolName: TId; transformCode?: string };
618
+ input?: InputForTool<TTools, TId, TSteps>;
619
+ };
620
+ }[TTools[number]["id"]];
621
+
622
+ type StepOpts<TSteps extends string, TTools extends readonly ToolLike[]> =
623
+ | ToolCallStepOpts<TSteps, TTools>
624
+ | (BaseStepFields & { action: { code: string }; input?: StepInput<TSteps> });
625
+
626
+ type ToolCallForEachOpts<
627
+ TSteps extends string,
628
+ TTools extends readonly ToolLike[],
629
+ > = [TTools[number]] extends [never]
630
+ ? BaseForEachFields & {
631
+ action: { toolName: string & {}; transformCode?: string };
632
+ input?: StepInput<TSteps>;
633
+ concurrency?: number;
634
+ }
635
+ : {
636
+ [TId in TTools[number]["id"]]: BaseForEachFields & {
637
+ action: { toolName: TId; transformCode?: string };
638
+ input?: InputForTool<TTools, TId, TSteps>;
639
+ concurrency?: number;
640
+ };
641
+ }[TTools[number]["id"]];
642
+
643
+ type ForEachItemOpts<
644
+ TSteps extends string,
645
+ TTools extends readonly ToolLike[],
646
+ > =
647
+ | ToolCallForEachOpts<TSteps, TTools>
648
+ | (BaseForEachFields & {
649
+ action: { code: string };
650
+ input?: StepInput<TSteps>;
651
+ concurrency?: number;
652
+ });
653
+
654
+ class WorkflowBuilder<
655
+ TSteps extends string = never,
656
+ TTools extends readonly ToolLike[] = never[],
657
+ > {
658
+ private readonly _steps: Step[] = [];
659
+ private readonly _tools: readonly ToolLike[];
660
+
661
+ constructor(
662
+ private readonly meta: Omit<WorkflowDefinition, "steps">,
663
+ tools: readonly ToolLike[] = [],
664
+ ) {
665
+ this._tools = tools;
666
+ }
667
+
668
+ /**
669
+ * Auto-injects the referenced tool's outputSchema into the step if:
670
+ * 1. The step action is a tool call (has toolName)
671
+ * 2. The matched tool has an outputSchema
672
+ * 3. The step does not already have an explicit outputSchema
673
+ *
674
+ * This ensures that when a tool's outputSchema changes, the workflow
675
+ * fingerprint changes and the sync correctly updates the stored workflow.
676
+ */
677
+ private _withToolSchema(step: Step): Step {
678
+ const action = step.action as { toolName?: string } | undefined;
679
+ if (!action?.toolName) return step;
680
+ const tool = this._tools.find((t) => t.id === action.toolName);
681
+ if (!tool?.outputSchema || step.outputSchema !== undefined) return step;
682
+ return {
683
+ ...step,
684
+ outputSchema: z.toJSONSchema(tool.outputSchema) as Step["outputSchema"],
685
+ };
686
+ }
687
+
688
+ step<TName extends string>(
689
+ name: TName,
690
+ opts: StepOpts<TSteps, TTools>,
691
+ ): WorkflowBuilder<TSteps | TName, TTools> {
692
+ this._steps.push(
693
+ this._withToolSchema({ name, ...(opts as Omit<Step, "name">) }),
694
+ );
695
+ return this as unknown as WorkflowBuilder<TSteps | TName, TTools>;
696
+ }
697
+
698
+ /**
699
+ * Creates a step that iterates over an array resolved from a @ref.
700
+ * Maps to the engine's Step.forEach field.
701
+ *
702
+ * @param name - Unique step name
703
+ * @param ref - @ref to the array to iterate (e.g. "@fetch_users")
704
+ * @param opts - Step definition (action, input, config, outputSchema)
705
+ */
706
+ forEachItem<TName extends string>(
707
+ name: TName,
708
+ ref: KnownRefs<TSteps>,
709
+ opts: ForEachItemOpts<TSteps, TTools>,
710
+ ): WorkflowBuilder<TSteps | TName, TTools> {
711
+ const { concurrency = 1, ...rest } = opts;
712
+ this._steps.push(
713
+ this._withToolSchema({
714
+ name,
715
+ ...(rest as Omit<Step, "name" | "forEach">),
716
+ forEach: { ref, concurrency },
717
+ }),
718
+ );
719
+ return this as unknown as WorkflowBuilder<TSteps | TName, TTools>;
720
+ }
721
+
722
+ /**
723
+ * Spreads an array of pre-built steps into the workflow.
724
+ * Step names from the array are not tracked in the type — use .step() for
725
+ * tracked composition.
726
+ */
727
+ addSteps(steps: Step[]): this {
728
+ this._steps.push(...steps);
729
+ return this;
730
+ }
731
+
732
+ build(): WorkflowDefinition {
733
+ return { ...this.meta, steps: [...this._steps] };
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Fluent builder for workflow definitions.
739
+ *
740
+ * Pass your locally-declared tools as the second argument to get autocomplete
741
+ * for `toolName` throughout the workflow. Step names are also tracked so
742
+ * `@ref` strings autocomplete in `input` after each `.step()` call.
743
+ *
744
+ * @example
745
+ * const GET_USERS = createTool({ id: "GET_USERS", ... });
746
+ * const PROCESS_USER = createTool({ id: "PROCESS_USER", ... });
747
+ *
748
+ * const myWorkflow = createWorkflow(
749
+ * { title: "Fetch and Process" },
750
+ * [GET_USERS, PROCESS_USER],
751
+ * )
752
+ * .step("fetch_users", {
753
+ * action: { toolName: "GET_USERS" }, // ← autocomplete: "GET_USERS" | "PROCESS_USER"
754
+ * })
755
+ * .forEachItem("process_user", "@fetch_users", {
756
+ * // ^ autocomplete: @fetch_users, @input, @item...
757
+ * action: { toolName: "PROCESS_USER" },
758
+ * input: { userId: "@item.id" },
759
+ * })
760
+ * .build();
761
+ */
762
+ export function createWorkflow<TTools extends readonly ToolLike[] = never[]>(
763
+ meta: Omit<WorkflowDefinition, "steps">,
764
+ tools?: TTools,
765
+ ): WorkflowBuilder<never, TTools> {
766
+ return new WorkflowBuilder(meta, tools ?? []) as unknown as WorkflowBuilder<
767
+ never,
768
+ TTools
769
+ >;
770
+ }