@decocms/runtime 1.2.15 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.2.15",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
@@ -22,7 +22,9 @@
22
22
  "./bindings": "./src/bindings/index.ts",
23
23
  "./asset-server": "./src/asset-server/index.ts",
24
24
  "./tools": "./src/tools.ts",
25
- "./decopilot": "./src/decopilot.ts"
25
+ "./decopilot": "./src/decopilot.ts",
26
+ "./triggers": "./src/triggers.ts",
27
+ "./trigger-storage": "./src/trigger-storage.ts"
26
28
  },
27
29
  "peerDependencies": {
28
30
  "ai": ">=6.0.0"
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) {
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Built-in TriggerStorage implementations.
3
+ *
4
+ * - StudioKV: Persists to Mesh/Studio's KV API (recommended for production)
5
+ * - JsonFileStorage: Persists to a local JSON file (for dev/simple deployments)
6
+ */
7
+
8
+ import type { TriggerStorage } from "./triggers.ts";
9
+
10
+ // ============================================================================
11
+ // StudioKV — backed by Mesh's /api/kv endpoint
12
+ // ============================================================================
13
+
14
+ interface StudioKVOptions {
15
+ /** Mesh/Studio base URL (e.g., "https://studio.example.com") */
16
+ url: string;
17
+ /** API key created in the Studio org */
18
+ apiKey: string;
19
+ /** Key prefix to namespace trigger data (default: "triggers") */
20
+ prefix?: string;
21
+ }
22
+
23
+ /**
24
+ * TriggerStorage backed by Mesh/Studio's org-scoped KV API.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import { createTriggers } from "@decocms/runtime/triggers";
29
+ * import { StudioKV } from "@decocms/runtime/trigger-storage";
30
+ *
31
+ * const triggers = createTriggers({
32
+ * definitions: [...],
33
+ * storage: new StudioKV({
34
+ * url: process.env.MESH_URL!,
35
+ * apiKey: process.env.MESH_API_KEY!,
36
+ * }),
37
+ * });
38
+ * ```
39
+ */
40
+ export class StudioKV implements TriggerStorage {
41
+ private baseUrl: string;
42
+ private apiKey: string;
43
+ private prefix: string;
44
+
45
+ constructor(options: StudioKVOptions) {
46
+ this.baseUrl = options.url.replace(/\/$/, "");
47
+ this.apiKey = options.apiKey;
48
+ this.prefix = options.prefix ?? "triggers";
49
+ }
50
+
51
+ private key(connectionId: string): string {
52
+ return `${this.prefix}:${connectionId}`;
53
+ }
54
+
55
+ async get(connectionId: string) {
56
+ const res = await fetch(
57
+ `${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
58
+ {
59
+ headers: { Authorization: `Bearer ${this.apiKey}` },
60
+ },
61
+ );
62
+
63
+ if (res.status === 404) return null;
64
+
65
+ if (!res.ok) {
66
+ console.error(`[StudioKV] GET failed: ${res.status} ${res.statusText}`);
67
+ return null;
68
+ }
69
+
70
+ const body = (await res.json()) as {
71
+ value?: {
72
+ credentials: { callbackUrl: string; callbackToken: string };
73
+ activeTriggerTypes: string[];
74
+ };
75
+ };
76
+ return body.value ?? null;
77
+ }
78
+
79
+ async set(
80
+ connectionId: string,
81
+ state: {
82
+ credentials: { callbackUrl: string; callbackToken: string };
83
+ activeTriggerTypes: string[];
84
+ },
85
+ ) {
86
+ const res = await fetch(
87
+ `${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
88
+ {
89
+ method: "PUT",
90
+ headers: {
91
+ Authorization: `Bearer ${this.apiKey}`,
92
+ "Content-Type": "application/json",
93
+ },
94
+ body: JSON.stringify(state),
95
+ },
96
+ );
97
+
98
+ if (!res.ok) {
99
+ console.error(`[StudioKV] PUT failed: ${res.status} ${res.statusText}`);
100
+ }
101
+ }
102
+
103
+ async delete(connectionId: string) {
104
+ const res = await fetch(
105
+ `${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
106
+ {
107
+ method: "DELETE",
108
+ headers: { Authorization: `Bearer ${this.apiKey}` },
109
+ },
110
+ );
111
+
112
+ if (!res.ok && res.status !== 404) {
113
+ console.error(
114
+ `[StudioKV] DELETE failed: ${res.status} ${res.statusText}`,
115
+ );
116
+ }
117
+ }
118
+ }
119
+
120
+ // ============================================================================
121
+ // JsonFileStorage — backed by a local JSON file
122
+ // ============================================================================
123
+
124
+ interface JsonFileStorageOptions {
125
+ /** Path to the JSON file (will be created if it doesn't exist) */
126
+ path: string;
127
+ }
128
+
129
+ /**
130
+ * TriggerStorage backed by a local JSON file.
131
+ * Suitable for development and single-instance deployments.
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * import { createTriggers } from "@decocms/runtime/triggers";
136
+ * import { JsonFileStorage } from "@decocms/runtime/trigger-storage";
137
+ *
138
+ * const triggers = createTriggers({
139
+ * definitions: [...],
140
+ * storage: new JsonFileStorage({ path: "./trigger-state.json" }),
141
+ * });
142
+ * ```
143
+ */
144
+ export class JsonFileStorage implements TriggerStorage {
145
+ private path: string;
146
+ private cache: Map<string, unknown> | null = null;
147
+
148
+ constructor(options: JsonFileStorageOptions) {
149
+ this.path = options.path;
150
+ }
151
+
152
+ private async load(): Promise<Map<string, unknown>> {
153
+ if (this.cache) return this.cache;
154
+ try {
155
+ const fs = await import("node:fs/promises");
156
+ const raw = await fs.readFile(this.path, "utf-8");
157
+ const data = JSON.parse(raw) as Record<string, unknown>;
158
+ this.cache = new Map(Object.entries(data));
159
+ } catch (err: unknown) {
160
+ if (
161
+ err instanceof Error &&
162
+ "code" in err &&
163
+ (err as NodeJS.ErrnoException).code === "ENOENT"
164
+ ) {
165
+ this.cache = new Map();
166
+ } else {
167
+ throw err;
168
+ }
169
+ }
170
+ return this.cache;
171
+ }
172
+
173
+ private async save(): Promise<void> {
174
+ const data = Object.fromEntries(this.cache ?? new Map());
175
+ const fs = await import("node:fs/promises");
176
+ await fs.writeFile(this.path, JSON.stringify(data, null, 2));
177
+ }
178
+
179
+ async get(connectionId: string) {
180
+ const map = await this.load();
181
+ return (map.get(connectionId) as any) ?? null;
182
+ }
183
+
184
+ async set(connectionId: string, state: unknown) {
185
+ const map = await this.load();
186
+ map.set(connectionId, state);
187
+ await this.save();
188
+ }
189
+
190
+ async delete(connectionId: string) {
191
+ const map = await this.load();
192
+ map.delete(connectionId);
193
+ await this.save();
194
+ }
195
+ }