@decocms/runtime 1.3.0 → 1.3.1

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/tools.ts +178 -118
  3. package/src/workflows.ts +50 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
package/src/tools.ts CHANGED
@@ -9,6 +9,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { WebStandardStreamableHTTPServerTransport as HttpServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
10
10
  import type {
11
11
  GetPromptResult,
12
+ Implementation,
12
13
  ToolAnnotations,
13
14
  } from "@modelcontextprotocol/sdk/types.js";
14
15
  import { z } from "zod";
@@ -485,6 +486,7 @@ export interface CreateMCPServerOptions<
485
486
  State extends
486
487
  TEnv["MESH_REQUEST_CONTEXT"]["state"] = TEnv["MESH_REQUEST_CONTEXT"]["state"],
487
488
  > {
489
+ serverInfo?: Partial<Implementation> & { instructions?: string };
488
490
  before?: (env: TEnv) => Promise<void> | void;
489
491
  oauth?: OAuthConfig;
490
492
  events?: {
@@ -711,14 +713,21 @@ const toolsFor = <TSchema extends ZodTypeAny = never>({
711
713
  ...(workflows?.length
712
714
  ? workflows.map((wf) => {
713
715
  const id = wf.toolId ?? workflowToolId(wf.title);
716
+ const baseDescription = [
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(" ");
714
722
  return createTool({
715
723
  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(" "),
724
+ description: (() => {
725
+ if (!wf.inputSchema) return baseDescription;
726
+ const schemaStr = JSON.stringify(wf.inputSchema, null, 2);
727
+ return schemaStr.length <= 2048
728
+ ? `${baseDescription}\n\nInput schema:\n${schemaStr}`
729
+ : `${baseDescription}\n\nThis workflow expects structured input. Use COLLECTION_WORKFLOW_GET to inspect the full input schema.`;
730
+ })(),
722
731
  inputSchema: z.object({
723
732
  input: z
724
733
  .record(z.string(), z.unknown())
@@ -808,44 +817,118 @@ export const createMCPServer = <
808
817
  >(
809
818
  options: CreateMCPServerOptions<TEnv, TSchema, TBindings>,
810
819
  ): MCPServer<TEnv, TSchema, TBindings> => {
811
- const createServer = async (bindings: TEnv) => {
812
- await options.before?.(bindings);
820
+ // Tool/prompt/resource definitions are resolved once on first request and
821
+ // cached for the lifetime of the process. Tool *execution* reads per-request
822
+ // context from State (AsyncLocalStorage), so reusing definitions is safe.
823
+ type Registrations = {
824
+ tools: CreatedTool[];
825
+ prompts: CreatedPrompt[];
826
+ resources: CreatedResource[];
827
+ workflows?: WorkflowDefinition[];
828
+ };
813
829
 
814
- const server = new McpServer(
815
- { name: "@deco/mcp-api", version: "1.0.0" },
816
- { capabilities: { tools: {}, prompts: {}, resources: {} } },
817
- );
830
+ let cached: Registrations | null = null;
831
+ let inflightResolve: Promise<Registrations> | null = null;
818
832
 
819
- const toolsFn =
820
- typeof options.tools === "function"
821
- ? options.tools
822
- : async (bindings: TEnv) => {
823
- if (typeof options.tools === "function") {
824
- return await options.tools(bindings);
825
- }
826
- return await Promise.all(
827
- options.tools?.flatMap(async (tool) => {
828
- const toolResult = tool(bindings);
829
- const awaited = await toolResult;
830
- if (Array.isArray(awaited)) {
831
- return awaited;
832
- }
833
- return [awaited];
834
- }) ?? [],
835
- ).then((t) => t.flat());
836
- };
837
- const tools = await toolsFn(bindings);
833
+ const resolveRegistrations = async (
834
+ bindings: TEnv,
835
+ ): Promise<Registrations> => {
836
+ if (cached) return cached;
837
+ if (inflightResolve) return inflightResolve;
838
838
 
839
- const resolvedWorkflows =
840
- typeof options.workflows === "function"
841
- ? await options.workflows(bindings)
842
- : options.workflows;
839
+ inflightResolve = (async (): Promise<Registrations> => {
840
+ try {
841
+ const toolsFn =
842
+ typeof options.tools === "function"
843
+ ? options.tools
844
+ : async (bindings: TEnv) => {
845
+ if (typeof options.tools === "function") {
846
+ return await options.tools(bindings);
847
+ }
848
+ return await Promise.all(
849
+ options.tools?.flatMap(async (tool) => {
850
+ const toolResult = tool(bindings);
851
+ const awaited = await toolResult;
852
+ if (Array.isArray(awaited)) {
853
+ return awaited;
854
+ }
855
+ return [awaited];
856
+ }) ?? [],
857
+ ).then((t) => t.flat());
858
+ };
859
+ const tools = await toolsFn(bindings);
860
+
861
+ const resolvedWorkflows =
862
+ typeof options.workflows === "function"
863
+ ? await options.workflows(bindings)
864
+ : options.workflows;
865
+
866
+ tools.push(
867
+ ...toolsFor<TSchema>({ ...options, workflows: resolvedWorkflows }),
868
+ );
869
+
870
+ const promptsFn =
871
+ typeof options.prompts === "function"
872
+ ? options.prompts
873
+ : async (bindings: TEnv) => {
874
+ if (typeof options.prompts === "function") {
875
+ return await options.prompts(bindings);
876
+ }
877
+ return await Promise.all(
878
+ options.prompts?.flatMap(async (prompt) => {
879
+ const promptResult = prompt(bindings);
880
+ const awaited = await promptResult;
881
+ if (Array.isArray(awaited)) {
882
+ return awaited;
883
+ }
884
+ return [awaited];
885
+ }) ?? [],
886
+ ).then((p) => p.flat());
887
+ };
888
+ const prompts = await promptsFn(bindings);
889
+
890
+ const resourcesFn =
891
+ typeof options.resources === "function"
892
+ ? options.resources
893
+ : async (bindings: TEnv) => {
894
+ if (typeof options.resources === "function") {
895
+ return await options.resources(bindings);
896
+ }
897
+ return await Promise.all(
898
+ options.resources?.flatMap(async (resource) => {
899
+ const resourceResult = resource(bindings);
900
+ const awaited = await resourceResult;
901
+ if (Array.isArray(awaited)) {
902
+ return awaited;
903
+ }
904
+ return [awaited];
905
+ }) ?? [],
906
+ ).then((r) => r.flat());
907
+ };
908
+ const resources = await resourcesFn(bindings);
909
+
910
+ const result = {
911
+ tools,
912
+ prompts,
913
+ resources,
914
+ workflows: resolvedWorkflows,
915
+ };
916
+ cached = result;
917
+ return result;
918
+ } catch (err) {
919
+ inflightResolve = null;
920
+ throw err;
921
+ }
922
+ })();
843
923
 
844
- tools.push(
845
- ...toolsFor<TSchema>({ ...options, workflows: resolvedWorkflows }),
846
- );
924
+ return inflightResolve;
925
+ };
847
926
 
848
- for (const tool of tools) {
927
+ const registerAll = (
928
+ server: McpServer,
929
+ registrations: Registrations,
930
+ ) => {
931
+ for (const tool of registrations.tools) {
849
932
  server.registerTool(
850
933
  tool.id,
851
934
  {
@@ -873,9 +956,8 @@ export const createMCPServer = <
873
956
  runtimeContext: createRuntimeContext(),
874
957
  });
875
958
 
876
- // For streamable tools, the Response is handled at the transport layer
877
- // Do NOT call result.bytes() - it buffers the entire response in memory
878
- // causing massive memory leaks (2GB+ Uint8Array accumulation)
959
+ // For streamable tools, the Response is handled at the transport layer.
960
+ // Do NOT call result.bytes() it buffers the entire body in memory.
879
961
  if (isStreamableTool(tool) && result instanceof Response) {
880
962
  return {
881
963
  structuredContent: {
@@ -904,28 +986,7 @@ export const createMCPServer = <
904
986
  );
905
987
  }
906
988
 
907
- // Resolve and register prompts
908
- const promptsFn =
909
- typeof options.prompts === "function"
910
- ? options.prompts
911
- : async (bindings: TEnv) => {
912
- if (typeof options.prompts === "function") {
913
- return await options.prompts(bindings);
914
- }
915
- return await Promise.all(
916
- options.prompts?.flatMap(async (prompt) => {
917
- const promptResult = prompt(bindings);
918
- const awaited = await promptResult;
919
- if (Array.isArray(awaited)) {
920
- return awaited;
921
- }
922
- return [awaited];
923
- }) ?? [],
924
- ).then((p) => p.flat());
925
- };
926
- const prompts = await promptsFn(bindings);
927
-
928
- for (const prompt of prompts) {
989
+ for (const prompt of registrations.prompts) {
929
990
  server.registerPrompt(
930
991
  prompt.name,
931
992
  {
@@ -944,28 +1005,7 @@ export const createMCPServer = <
944
1005
  );
945
1006
  }
946
1007
 
947
- // Resolve and register resources
948
- const resourcesFn =
949
- typeof options.resources === "function"
950
- ? options.resources
951
- : async (bindings: TEnv) => {
952
- if (typeof options.resources === "function") {
953
- return await options.resources(bindings);
954
- }
955
- return await Promise.all(
956
- options.resources?.flatMap(async (resource) => {
957
- const resourceResult = resource(bindings);
958
- const awaited = await resourceResult;
959
- if (Array.isArray(awaited)) {
960
- return awaited;
961
- }
962
- return [awaited];
963
- }) ?? [],
964
- ).then((r) => r.flat());
965
- };
966
- const resources = await resourcesFn(bindings);
967
-
968
- for (const resource of resources) {
1008
+ for (const resource of registrations.resources) {
969
1009
  server.resource(
970
1010
  resource.name,
971
1011
  resource.uri,
@@ -978,19 +1018,7 @@ export const createMCPServer = <
978
1018
  uri,
979
1019
  runtimeContext: createRuntimeContext(),
980
1020
  });
981
- // Build content object based on what's provided (text or blob, not both)
982
- const content: {
983
- uri: string;
984
- mimeType?: string;
985
- text?: string;
986
- blob?: string;
987
- } = { uri: result.uri };
988
-
989
- if (result.mimeType) {
990
- content.mimeType = result.mimeType;
991
- }
992
1021
 
993
- // MCP SDK expects either text or blob content, not both
994
1022
  const meta =
995
1023
  (result as { _meta?: Record<string, unknown> | null })._meta ??
996
1024
  undefined;
@@ -1018,7 +1046,6 @@ export const createMCPServer = <
1018
1046
  };
1019
1047
  }
1020
1048
 
1021
- // Fallback to empty text if neither provided
1022
1049
  return {
1023
1050
  contents: [
1024
1051
  { uri: result.uri, mimeType: result.mimeType, text: "" },
@@ -1027,8 +1054,28 @@ export const createMCPServer = <
1027
1054
  },
1028
1055
  );
1029
1056
  }
1057
+ };
1058
+
1059
+ const createServer = async (bindings: TEnv) => {
1060
+ await options.before?.(bindings);
1061
+
1062
+ const { instructions, ...serverInfoOverrides } = options.serverInfo ?? {};
1063
+ const server = new McpServer(
1064
+ {
1065
+ ...serverInfoOverrides,
1066
+ name: serverInfoOverrides.name ?? "@deco/mcp-api",
1067
+ version: serverInfoOverrides.version ?? "1.0.0",
1068
+ },
1069
+ {
1070
+ capabilities: { tools: {}, prompts: {}, resources: {} },
1071
+ ...(instructions && { instructions }),
1072
+ },
1073
+ );
1030
1074
 
1031
- return { server, tools, prompts, resources };
1075
+ const registrations = await resolveRegistrations(bindings);
1076
+ registerAll(server, registrations);
1077
+
1078
+ return { server, ...registrations };
1032
1079
  };
1033
1080
 
1034
1081
  const fetch = async (req: Request, env: TEnv) => {
@@ -1037,34 +1084,45 @@ export const createMCPServer = <
1037
1084
 
1038
1085
  await server.connect(transport);
1039
1086
 
1087
+ const cleanup = () => {
1088
+ try {
1089
+ transport.close?.();
1090
+ } catch {
1091
+ /* ignore */
1092
+ }
1093
+ try {
1094
+ server.close?.();
1095
+ } catch {
1096
+ /* ignore */
1097
+ }
1098
+ };
1099
+
1040
1100
  try {
1041
1101
  const response = await transport.handleRequest(req);
1042
1102
 
1043
- // Check if this is a streaming response (SSE or streamable tool)
1044
- // SSE responses have text/event-stream content-type
1045
- // Note: response.body is always non-null for all HTTP responses, so we can't use it to detect streaming
1046
1103
  const contentType = response.headers.get("content-type");
1047
1104
  const isStreaming =
1048
1105
  contentType?.includes("text/event-stream") ||
1049
1106
  contentType?.includes("application/json-rpc");
1050
1107
 
1051
- // Only close transport for non-streaming responses
1052
- if (!isStreaming) {
1053
- try {
1054
- await transport.close?.();
1055
- } catch {
1056
- // Ignore close errors
1057
- }
1108
+ if (!isStreaming || !response.body) {
1109
+ cleanup();
1110
+ return response;
1058
1111
  }
1059
1112
 
1060
- return response;
1113
+ // Pipe the SSE body through a passthrough so that when the stream
1114
+ // finishes (server sent the response) or the client disconnects
1115
+ // (cancel), the server and transport are always cleaned up.
1116
+ const { readable, writable } = new TransformStream();
1117
+ response.body.pipeTo(writable).catch(() => {}).finally(cleanup);
1118
+
1119
+ return new Response(readable, {
1120
+ status: response.status,
1121
+ statusText: response.statusText,
1122
+ headers: response.headers,
1123
+ });
1061
1124
  } catch (error) {
1062
- // On error, always try to close transport to prevent leaks
1063
- try {
1064
- await transport.close?.();
1065
- } catch {
1066
- // Ignore close errors
1067
- }
1125
+ cleanup();
1068
1126
  throw error;
1069
1127
  }
1070
1128
  };
@@ -1075,7 +1133,9 @@ export const createMCPServer = <
1075
1133
  throw new Error("Missing state, did you forget to call State.bind?");
1076
1134
  }
1077
1135
  const env = currentState?.env;
1078
- const { tools } = await createServer(env as TEnv & DefaultEnv<TSchema>);
1136
+ const { tools } = await resolveRegistrations(
1137
+ env as TEnv & DefaultEnv<TSchema>,
1138
+ );
1079
1139
  const tool = tools.find((t) => t.id === toolCallId);
1080
1140
  const execute = tool?.execute;
1081
1141
  if (!execute) {
package/src/workflows.ts CHANGED
@@ -24,6 +24,12 @@ export interface WorkflowDefinition {
24
24
  * Defaults to START_WORKFLOW_<TITLE_SLUG> (e.g. START_WORKFLOW_FETCH_USERS).
25
25
  */
26
26
  toolId?: string;
27
+ /**
28
+ * JSON Schema describing the expected input for this workflow.
29
+ * When set, the mesh validates execution input against this schema
30
+ * before creating an execution.
31
+ */
32
+ inputSchema?: Record<string, unknown> | null;
27
33
  }
28
34
 
29
35
  interface WorkflowCollectionItem {
@@ -67,6 +73,7 @@ interface MeshWorkflowClient {
67
73
  description?: string;
68
74
  virtual_mcp_id?: string;
69
75
  steps: Step[];
76
+ input_schema?: Record<string, unknown> | null;
70
77
  };
71
78
  }) => Promise<{ item: WorkflowCollectionItem }>;
72
79
  COLLECTION_WORKFLOW_UPDATE: (input: {
@@ -76,6 +83,7 @@ interface MeshWorkflowClient {
76
83
  description?: string;
77
84
  virtual_mcp_id?: string;
78
85
  steps?: Step[];
86
+ input_schema?: Record<string, unknown> | null;
79
87
  };
80
88
  }) => Promise<{ success: boolean; error?: string }>;
81
89
  COLLECTION_WORKFLOW_DELETE: (input: {
@@ -254,10 +262,11 @@ function fingerprintWorkflows(declared: WorkflowDefinition[]): string {
254
262
  return JSON.stringify(
255
263
  declared.map((w) => ({
256
264
  title: w.title,
257
- description: w.description ?? null,
258
- virtual_mcp_id: w.virtual_mcp_id ?? null,
265
+ description: w.description ?? undefined,
266
+ virtual_mcp_id: w.virtual_mcp_id ?? undefined,
259
267
  steps: w.steps,
260
- toolId: w.toolId ?? null,
268
+ toolId: w.toolId ?? undefined,
269
+ inputSchema: w.inputSchema ?? undefined,
261
270
  })),
262
271
  );
263
272
  }
@@ -383,6 +392,10 @@ async function doSyncWorkflows(
383
392
  virtual_mcp_id: resolvedVmcpId,
384
393
  }),
385
394
  steps: wf.steps,
395
+ input_schema:
396
+ wf.inputSchema === undefined
397
+ ? undefined
398
+ : (wf.inputSchema ?? null),
386
399
  },
387
400
  });
388
401
  if (!result.success) {
@@ -402,6 +415,7 @@ async function doSyncWorkflows(
402
415
  description: wf.description,
403
416
  virtual_mcp_id: resolvedVmcpId,
404
417
  steps: wf.steps,
418
+ input_schema: wf.inputSchema ?? null,
405
419
  },
406
420
  });
407
421
  console.log(`${tag} CREATE "${wf.title}" OK`);
@@ -596,8 +610,30 @@ type InputForTool<
596
610
  : StepInput<TSteps>
597
611
  : StepInput<TSteps>;
598
612
 
599
- type BaseStepFields = Omit<Step, "name" | "input" | "action">;
600
- type BaseForEachFields = Omit<Step, "name" | "forEach" | "input" | "action">;
613
+ /**
614
+ * Typed bail condition with @ref autocomplete.
615
+ * Self-references (e.g. `@thisStep.field` on step "thisStep") are valid for
616
+ * bail — the condition is evaluated after the step completes — but the current
617
+ * step name isn't in TSteps yet. Use the `(string & {})` escape hatch.
618
+ */
619
+ type TypedBail<TSteps extends string> =
620
+ | true
621
+ | {
622
+ ref: KnownRefs<TSteps>;
623
+ eq?: unknown;
624
+ neq?: unknown;
625
+ gt?: number;
626
+ lt?: number;
627
+ };
628
+
629
+ type BaseStepFields<TSteps extends string> = Omit<
630
+ Step,
631
+ "name" | "input" | "action" | "bail"
632
+ > & { bail?: TypedBail<TSteps> };
633
+ type BaseForEachFields<TSteps extends string> = Omit<
634
+ Step,
635
+ "name" | "forEach" | "input" | "action" | "bail"
636
+ > & { bail?: TypedBail<TSteps> };
601
637
 
602
638
  /**
603
639
  * Tool-call variants of StepOpts — one discriminated member per tool ID so
@@ -608,12 +644,12 @@ type ToolCallStepOpts<
608
644
  TSteps extends string,
609
645
  TTools extends readonly ToolLike[],
610
646
  > = [TTools[number]] extends [never]
611
- ? BaseStepFields & {
647
+ ? BaseStepFields<TSteps> & {
612
648
  action: { toolName: string & {}; transformCode?: string };
613
649
  input?: StepInput<TSteps>;
614
650
  }
615
651
  : {
616
- [TId in TTools[number]["id"]]: BaseStepFields & {
652
+ [TId in TTools[number]["id"]]: BaseStepFields<TSteps> & {
617
653
  action: { toolName: TId; transformCode?: string };
618
654
  input?: InputForTool<TTools, TId, TSteps>;
619
655
  };
@@ -621,19 +657,22 @@ type ToolCallStepOpts<
621
657
 
622
658
  type StepOpts<TSteps extends string, TTools extends readonly ToolLike[]> =
623
659
  | ToolCallStepOpts<TSteps, TTools>
624
- | (BaseStepFields & { action: { code: string }; input?: StepInput<TSteps> });
660
+ | (BaseStepFields<TSteps> & {
661
+ action: { code: string };
662
+ input?: StepInput<TSteps>;
663
+ });
625
664
 
626
665
  type ToolCallForEachOpts<
627
666
  TSteps extends string,
628
667
  TTools extends readonly ToolLike[],
629
668
  > = [TTools[number]] extends [never]
630
- ? BaseForEachFields & {
669
+ ? BaseForEachFields<TSteps> & {
631
670
  action: { toolName: string & {}; transformCode?: string };
632
671
  input?: StepInput<TSteps>;
633
672
  concurrency?: number;
634
673
  }
635
674
  : {
636
- [TId in TTools[number]["id"]]: BaseForEachFields & {
675
+ [TId in TTools[number]["id"]]: BaseForEachFields<TSteps> & {
637
676
  action: { toolName: TId; transformCode?: string };
638
677
  input?: InputForTool<TTools, TId, TSteps>;
639
678
  concurrency?: number;
@@ -645,7 +684,7 @@ type ForEachItemOpts<
645
684
  TTools extends readonly ToolLike[],
646
685
  > =
647
686
  | ToolCallForEachOpts<TSteps, TTools>
648
- | (BaseForEachFields & {
687
+ | (BaseForEachFields<TSteps> & {
649
688
  action: { code: string };
650
689
  input?: StepInput<TSteps>;
651
690
  concurrency?: number;