@decocms/runtime 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -152,37 +152,6 @@ const getUserDataTool = createPrivateTool({
152
152
  });
153
153
  ```
154
154
 
155
- ### Streamable Tools
156
-
157
- For tools that return streaming responses:
158
-
159
- ```typescript
160
- import { createStreamableTool } from "@decocms/runtime";
161
-
162
- const streamDataTool = createStreamableTool({
163
- id: "streamData",
164
- description: "Streams data as a response",
165
- inputSchema: z.object({
166
- query: z.string(),
167
- }),
168
- streamable: true,
169
- execute: async ({ context }) => {
170
- // Return a streaming Response
171
- const stream = new ReadableStream({
172
- async start(controller) {
173
- controller.enqueue(new TextEncoder().encode("Chunk 1\n"));
174
- controller.enqueue(new TextEncoder().encode("Chunk 2\n"));
175
- controller.close();
176
- },
177
- });
178
-
179
- return new Response(stream, {
180
- headers: { "Content-Type": "text/plain" },
181
- });
182
- },
183
- });
184
- ```
185
-
186
155
  ### Registering Tools
187
156
 
188
157
  Tools can be registered in multiple ways:
@@ -701,7 +670,6 @@ export default withRuntime({
701
670
  ### Types
702
671
 
703
672
  - `Tool<TSchemaIn, TSchemaOut>` - Tool definition with typed input/output
704
- - `StreamableTool<TSchemaIn>` - Tool that returns streaming Response
705
673
  - `Prompt<TArgs>` - Prompt definition with typed arguments
706
674
  - `Resource` - Resource definition
707
675
  - `OAuthConfig` - OAuth configuration
@@ -714,7 +682,6 @@ export default withRuntime({
714
682
  - `withRuntime(options)` - Create an MCP server
715
683
  - `createTool(opts)` - Create a public tool
716
684
  - `createPrivateTool(opts)` - Create an authenticated tool
717
- - `createStreamableTool(opts)` - Create a streaming tool
718
685
  - `createPrompt(opts)` - Create an authenticated prompt
719
686
  - `createPublicPrompt(opts)` - Create a public prompt
720
687
  - `createResource(opts)` - Create an authenticated resource
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
@@ -7,7 +7,7 @@ import {
7
7
  type MCPClientFetchStub,
8
8
  type ToolBinder,
9
9
  } from "../mcp.ts";
10
- import { createPrivateTool, createStreamableTool } from "../tools.ts";
10
+ import { createPrivateTool } from "../tools.ts";
11
11
  import { CHANNEL_BINDING } from "./channels.ts";
12
12
 
13
13
  // ToolLike is a simplified version of the Tool interface that matches what we need for bindings
@@ -77,13 +77,6 @@ export const bindingClient = <TDefinition extends readonly ToolBinder[]>(
77
77
  ): MCPClientFetchStub<TDefinition> => {
78
78
  return createMCPFetchStub<TDefinition>({
79
79
  connection: mcpConnection,
80
- streamable: binder.reduce(
81
- (acc, tool) => {
82
- acc[tool.name] = tool.streamable === true;
83
- return acc;
84
- },
85
- {} as Record<string, boolean>,
86
- ),
87
80
  });
88
81
  },
89
82
  };
@@ -99,12 +92,8 @@ export const impl = <TBinder extends Binder>(
99
92
  schema: TBinder,
100
93
  implementation: BinderImplementation<TBinder>,
101
94
  createToolFn = createPrivateTool,
102
- createStreamableToolFn = createStreamableTool,
103
95
  ) => {
104
- const impl: (
105
- | ReturnType<typeof createToolFn>
106
- | ReturnType<typeof createStreamableToolFn>
107
- )[] = [];
96
+ const impl: ReturnType<typeof createToolFn>[] = [];
108
97
  for (const key in schema) {
109
98
  const toolSchema = schema[key];
110
99
  const toolImplementation = implementation[key];
@@ -117,28 +106,17 @@ export const impl = <TBinder extends Binder>(
117
106
  throw new Error(`Implementation for ${key} is required`);
118
107
  }
119
108
 
120
- const { name, handler, streamable, ...toolLike } = {
109
+ const { name, handler, ...toolLike } = {
121
110
  ...toolSchema,
122
111
  ...toolImplementation,
123
112
  };
124
- if (streamable) {
125
- impl.push(
126
- createStreamableToolFn({
127
- ...toolLike,
128
- streamable,
129
- id: name,
130
- execute: ({ context }) => Promise.resolve(handler(context)),
131
- }),
132
- );
133
- } else {
134
- impl.push(
135
- createToolFn({
136
- ...toolLike,
137
- id: name,
138
- execute: ({ context }) => Promise.resolve(handler(context)),
139
- }),
140
- );
141
- }
113
+ impl.push(
114
+ createToolFn({
115
+ ...toolLike,
116
+ id: name,
117
+ execute: ({ context }) => Promise.resolve(handler(context)),
118
+ }),
119
+ );
142
120
  }
143
121
  return impl;
144
122
  };
package/src/bindings.ts CHANGED
@@ -161,7 +161,7 @@ export const AgentOf = () =>
161
161
  thinking: AgentModelInfoSchema.optional(),
162
162
  coding: AgentModelInfoSchema.optional(),
163
163
  fast: AgentModelInfoSchema.optional(),
164
- toolApprovalLevel: z.enum(["auto", "readonly", "plan"]).default("readonly"),
164
+ toolApprovalLevel: z.enum(["auto", "readonly", "plan"]).default("auto"),
165
165
  temperature: z.number().default(0.5),
166
166
  });
167
167
 
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export {
29
29
  type ResourceContents,
30
30
  type CreatedResource,
31
31
  type WorkflowDefinition,
32
+ ensureAuthenticated,
32
33
  } from "./tools.ts";
33
34
  export { createWorkflow } from "./workflows.ts";
34
35
  import type { Binding } from "./wrangler.ts";
package/src/tools.ts CHANGED
@@ -9,13 +9,14 @@ 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";
15
16
  import type { ZodRawShape, ZodSchema, ZodTypeAny } from "zod";
16
17
  import { BindingRegistry, injectBindingSchemas } from "./bindings.ts";
17
18
  import { Event, type EventHandlers } from "./events.ts";
18
- import type { DefaultEnv } from "./index.ts";
19
+ import type { DefaultEnv, User } from "./index.ts";
19
20
  import { State } from "./state.ts";
20
21
  import {
21
22
  type WorkflowDefinition,
@@ -66,25 +67,14 @@ export interface Tool<
66
67
  outputSchema?: TSchemaOut;
67
68
  execute(
68
69
  context: ToolExecutionContext<TSchemaIn>,
70
+ ctx?: AppContext,
69
71
  ): TSchemaOut extends ZodSchema
70
72
  ? Promise<z.infer<TSchemaOut>>
71
73
  : Promise<unknown>;
72
74
  }
73
75
 
74
76
  /**
75
- * Streamable tool interface for tools that return Response streams.
76
- */
77
- export interface StreamableTool<TSchemaIn extends ZodSchema = ZodSchema> {
78
- _meta?: Record<string, unknown>;
79
- id: string;
80
- inputSchema: TSchemaIn;
81
- streamable?: true;
82
- description?: string;
83
- execute(input: ToolExecutionContext<TSchemaIn>): Promise<Response>;
84
- }
85
-
86
- /**
87
- * CreatedTool is a permissive type that any Tool or StreamableTool can be assigned to.
77
+ * CreatedTool is a permissive type that any Tool can be assigned to.
88
78
  * Uses a structural type with relaxed execute signature to allow tools with any schema.
89
79
  */
90
80
  export type CreatedTool = {
@@ -94,12 +84,14 @@ export type CreatedTool = {
94
84
  annotations?: ToolAnnotations;
95
85
  inputSchema: ZodTypeAny;
96
86
  outputSchema?: ZodTypeAny;
97
- streamable?: true;
98
87
  // Use a permissive execute signature - accepts any context shape
99
- execute(context: {
100
- context: unknown;
101
- runtimeContext: AppContext;
102
- }): Promise<unknown>;
88
+ execute(
89
+ context: {
90
+ context: unknown;
91
+ runtimeContext: AppContext;
92
+ },
93
+ ctx?: AppContext,
94
+ ): Promise<unknown>;
103
95
  };
104
96
 
105
97
  // Re-export types for external use
@@ -136,6 +128,7 @@ export interface Prompt<TArgs extends PromptArgsRawShape = PromptArgsRawShape> {
136
128
  argsSchema?: TArgs;
137
129
  execute(
138
130
  context: PromptExecutionContext<TArgs>,
131
+ ctx?: AppContext,
139
132
  ): Promise<GetPromptResult> | GetPromptResult;
140
133
  }
141
134
 
@@ -149,10 +142,13 @@ export type CreatedPrompt = {
149
142
  description?: string;
150
143
  argsSchema?: PromptArgsRawShape;
151
144
  // Use a permissive execute signature - accepts any args shape
152
- execute(context: {
153
- args: Record<string, string | undefined>;
154
- runtimeContext: AppContext;
155
- }): Promise<GetPromptResult> | GetPromptResult;
145
+ execute(
146
+ context: {
147
+ args: Record<string, string | undefined>;
148
+ runtimeContext: AppContext;
149
+ },
150
+ ctx?: AppContext,
151
+ ): Promise<GetPromptResult> | GetPromptResult;
156
152
  };
157
153
 
158
154
  // ============================================================================
@@ -198,6 +194,7 @@ export interface Resource {
198
194
  /** Handler function to read the resource content */
199
195
  read(
200
196
  context: ResourceExecutionContext,
197
+ ctx?: AppContext,
201
198
  ): Promise<ResourceContents> | ResourceContents;
202
199
  }
203
200
 
@@ -210,48 +207,58 @@ export type CreatedResource = {
210
207
  name: string;
211
208
  description?: string;
212
209
  mimeType?: string;
213
- read(context: {
214
- uri: URL;
215
- runtimeContext: AppContext;
216
- }): Promise<ResourceContents> | ResourceContents;
210
+ read(
211
+ context: {
212
+ uri: URL;
213
+ runtimeContext: AppContext;
214
+ },
215
+ ctx?: AppContext,
216
+ ): Promise<ResourceContents> | ResourceContents;
217
217
  };
218
218
 
219
219
  /**
220
- * creates a private tool that always ensure for athentication before being executed
220
+ * Ensure the current request is authenticated.
221
+ * Reads from the per-request AppContext (AsyncLocalStorage), not from a cached env.
222
+ *
223
+ * @param ctx - Per-request AppContext from the second arg of execute/read handlers
224
+ * @returns The authenticated User
225
+ * @throws Error if no request context or user is not authenticated
226
+ */
227
+ export function ensureAuthenticated(ctx: AppContext): User {
228
+ const reqCtx = ctx?.env?.MESH_REQUEST_CONTEXT;
229
+ if (!reqCtx) {
230
+ throw new Error("Unauthorized: missing request context");
231
+ }
232
+ const user = reqCtx.ensureAuthenticated();
233
+ if (!user) {
234
+ throw new Error("Unauthorized");
235
+ }
236
+ return user;
237
+ }
238
+
239
+ let _warnedPrivateTool = false;
240
+
241
+ /**
242
+ * @deprecated Use `createTool` with `ensureAuthenticated(ctx)` instead.
243
+ *
244
+ * Creates a private tool that ensures authentication before execution.
221
245
  */
222
246
  export function createPrivateTool<
223
247
  TSchemaIn extends ZodSchema = ZodSchema,
224
248
  TSchemaOut extends ZodSchema | undefined = undefined,
225
249
  >(opts: Tool<TSchemaIn, TSchemaOut>): Tool<TSchemaIn, TSchemaOut> {
226
- const execute = opts.execute;
227
- if (typeof execute === "function") {
228
- opts.execute = (input: ToolExecutionContext<TSchemaIn>) => {
229
- const env = input.runtimeContext.env;
230
- if (env) {
231
- env.MESH_REQUEST_CONTEXT?.ensureAuthenticated();
232
- }
233
- return execute(input);
234
- };
250
+ if (!_warnedPrivateTool) {
251
+ console.warn(
252
+ "[runtime] createPrivateTool is deprecated. Use createTool with ensureAuthenticated(ctx) instead.",
253
+ );
254
+ _warnedPrivateTool = true;
235
255
  }
236
- return createTool(opts);
237
- }
238
-
239
- export function createStreamableTool<TSchemaIn extends ZodSchema = ZodSchema>(
240
- streamableTool: StreamableTool<TSchemaIn>,
241
- ): StreamableTool<TSchemaIn> {
242
- return {
243
- ...streamableTool,
244
- execute: (input: ToolExecutionContext<TSchemaIn>) => {
245
- const env = input.runtimeContext.env;
246
- if (env) {
247
- env.MESH_REQUEST_CONTEXT?.ensureAuthenticated();
248
- }
249
- return streamableTool.execute({
250
- ...input,
251
- runtimeContext: createRuntimeContext(input.runtimeContext),
252
- });
253
- },
256
+ const execute = opts.execute;
257
+ opts.execute = (input: ToolExecutionContext<TSchemaIn>, ctx: AppContext) => {
258
+ ensureAuthenticated(ctx);
259
+ return execute(input, ctx);
254
260
  };
261
+ return createTool(opts);
255
262
  }
256
263
 
257
264
  export function createTool<
@@ -262,10 +269,8 @@ export function createTool<
262
269
  return {
263
270
  ...opts,
264
271
  execute: (input: ToolExecutionContext<TSchemaIn>) => {
265
- return opts.execute({
266
- ...input,
267
- runtimeContext: createRuntimeContext(input.runtimeContext),
268
- });
272
+ const ctx = createRuntimeContext(input.runtimeContext);
273
+ return opts.execute({ ...input, runtimeContext: ctx }, ctx);
269
274
  },
270
275
  };
271
276
  }
@@ -279,10 +284,8 @@ export function createPublicPrompt<TArgs extends PromptArgsRawShape>(
279
284
  return {
280
285
  ...opts,
281
286
  execute: (input: PromptExecutionContext<TArgs>) => {
282
- return opts.execute({
283
- ...input,
284
- runtimeContext: createRuntimeContext(input.runtimeContext),
285
- });
287
+ const ctx = createRuntimeContext(input.runtimeContext);
288
+ return opts.execute({ ...input, runtimeContext: ctx }, ctx);
286
289
  },
287
290
  };
288
291
  }
@@ -297,12 +300,9 @@ export function createPrompt<TArgs extends PromptArgsRawShape>(
297
300
  const execute = opts.execute;
298
301
  return createPublicPrompt({
299
302
  ...opts,
300
- execute: (input: PromptExecutionContext<TArgs>) => {
301
- const env = input.runtimeContext.env;
302
- if (env) {
303
- env.MESH_REQUEST_CONTEXT?.ensureAuthenticated();
304
- }
305
- return execute(input);
303
+ execute: (input: PromptExecutionContext<TArgs>, ctx: AppContext) => {
304
+ ensureAuthenticated(ctx);
305
+ return execute(input, ctx);
306
306
  },
307
307
  });
308
308
  }
@@ -314,10 +314,8 @@ export function createPublicResource(opts: Resource): Resource {
314
314
  return {
315
315
  ...opts,
316
316
  read: (input: ResourceExecutionContext) => {
317
- return opts.read({
318
- ...input,
319
- runtimeContext: createRuntimeContext(input.runtimeContext),
320
- });
317
+ const ctx = createRuntimeContext(input.runtimeContext);
318
+ return opts.read({ ...input, runtimeContext: ctx }, ctx);
321
319
  },
322
320
  };
323
321
  }
@@ -330,12 +328,9 @@ export function createResource(opts: Resource): Resource {
330
328
  const read = opts.read;
331
329
  return createPublicResource({
332
330
  ...opts,
333
- read: (input: ResourceExecutionContext) => {
334
- const env = input.runtimeContext.env;
335
- if (env) {
336
- env.MESH_REQUEST_CONTEXT?.ensureAuthenticated();
337
- }
338
- return read(input);
331
+ read: (input: ResourceExecutionContext, ctx: AppContext) => {
332
+ ensureAuthenticated(ctx);
333
+ return read(input, ctx);
339
334
  },
340
335
  });
341
336
  }
@@ -354,12 +349,6 @@ export interface Integration {
354
349
  appId: string;
355
350
  }
356
351
 
357
- export function isStreamableTool(
358
- tool: CreatedTool,
359
- ): tool is StreamableTool & CreatedTool {
360
- return tool && "streamable" in tool && tool.streamable === true;
361
- }
362
-
363
352
  export interface OnChangeCallback<TState> {
364
353
  state: TState;
365
354
  scopes: string[];
@@ -485,6 +474,7 @@ export interface CreateMCPServerOptions<
485
474
  State extends
486
475
  TEnv["MESH_REQUEST_CONTEXT"]["state"] = TEnv["MESH_REQUEST_CONTEXT"]["state"],
487
476
  > {
477
+ serverInfo?: Partial<Implementation> & { instructions?: string };
488
478
  before?: (env: TEnv) => Promise<void> | void;
489
479
  oauth?: OAuthConfig;
490
480
  events?: {
@@ -498,35 +488,38 @@ export interface CreateMCPServerOptions<
498
488
  };
499
489
  tools?:
500
490
  | Array<
501
- (
502
- env: TEnv,
503
- ) =>
504
- | Promise<CreatedTool>
505
- | CreatedTool
506
- | CreatedTool[]
507
- | Promise<CreatedTool[]>
491
+ | CreatedTool
492
+ | ((
493
+ env: TEnv,
494
+ ) =>
495
+ | Promise<CreatedTool>
496
+ | CreatedTool
497
+ | CreatedTool[]
498
+ | Promise<CreatedTool[]>)
508
499
  >
509
500
  | ((env: TEnv) => CreatedTool[] | Promise<CreatedTool[]>);
510
501
  prompts?:
511
502
  | Array<
512
- (
513
- env: TEnv,
514
- ) =>
515
- | Promise<CreatedPrompt>
516
- | CreatedPrompt
517
- | CreatedPrompt[]
518
- | Promise<CreatedPrompt[]>
503
+ | CreatedPrompt
504
+ | ((
505
+ env: TEnv,
506
+ ) =>
507
+ | Promise<CreatedPrompt>
508
+ | CreatedPrompt
509
+ | CreatedPrompt[]
510
+ | Promise<CreatedPrompt[]>)
519
511
  >
520
512
  | ((env: TEnv) => CreatedPrompt[] | Promise<CreatedPrompt[]>);
521
513
  resources?:
522
514
  | Array<
523
- (
524
- env: TEnv,
525
- ) =>
526
- | Promise<CreatedResource>
527
- | CreatedResource
528
- | CreatedResource[]
529
- | Promise<CreatedResource[]>
515
+ | CreatedResource
516
+ | ((
517
+ env: TEnv,
518
+ ) =>
519
+ | Promise<CreatedResource>
520
+ | CreatedResource
521
+ | CreatedResource[]
522
+ | Promise<CreatedResource[]>)
530
523
  >
531
524
  | ((env: TEnv) => CreatedResource[] | Promise<CreatedResource[]>);
532
525
  workflows?:
@@ -711,14 +704,21 @@ const toolsFor = <TSchema extends ZodTypeAny = never>({
711
704
  ...(workflows?.length
712
705
  ? workflows.map((wf) => {
713
706
  const id = wf.toolId ?? workflowToolId(wf.title);
707
+ const baseDescription = [
708
+ wf.description
709
+ ? `Run workflow: ${wf.description}`
710
+ : `Start the "${wf.title}" workflow.`,
711
+ "Returns an execution_id immediately. Use COLLECTION_WORKFLOW_EXECUTION_GET to track progress.",
712
+ ].join(" ");
714
713
  return createTool({
715
714
  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(" "),
715
+ description: (() => {
716
+ if (!wf.inputSchema) return baseDescription;
717
+ const schemaStr = JSON.stringify(wf.inputSchema, null, 2);
718
+ return schemaStr.length <= 2048
719
+ ? `${baseDescription}\n\nInput schema:\n${schemaStr}`
720
+ : `${baseDescription}\n\nThis workflow expects structured input. Use COLLECTION_WORKFLOW_GET to inspect the full input schema.`;
721
+ })(),
722
722
  inputSchema: z.object({
723
723
  input: z
724
724
  .record(z.string(), z.unknown())
@@ -808,89 +808,155 @@ export const createMCPServer = <
808
808
  >(
809
809
  options: CreateMCPServerOptions<TEnv, TSchema, TBindings>,
810
810
  ): MCPServer<TEnv, TSchema, TBindings> => {
811
- const createServer = async (bindings: TEnv) => {
812
- await options.before?.(bindings);
811
+ // Tool/prompt/resource definitions are resolved once on first request and
812
+ // cached for the lifetime of the process. Tool *execution* reads per-request
813
+ // context from State (AsyncLocalStorage) via the second `ctx` argument, so
814
+ // reusing definitions is safe.
815
+ type Registrations = {
816
+ tools: CreatedTool[];
817
+ prompts: CreatedPrompt[];
818
+ resources: CreatedResource[];
819
+ workflows?: WorkflowDefinition[];
820
+ };
813
821
 
814
- const server = new McpServer(
815
- { name: "@deco/mcp-api", version: "1.0.0" },
816
- { capabilities: { tools: {}, prompts: {}, resources: {} } },
817
- );
822
+ let cached: Registrations | null = null;
823
+ let inflightResolve: Promise<Registrations> | null = null;
818
824
 
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);
825
+ let _warnedFactoryDeprecation = false;
826
+ const warnFactoryDeprecation = () => {
827
+ if (!_warnedFactoryDeprecation) {
828
+ console.warn(
829
+ "[runtime] Passing factory functions to tools/prompts/resources is deprecated. " +
830
+ "Pass createTool()/createPrompt()/createResource() instances directly.",
831
+ );
832
+ _warnedFactoryDeprecation = true;
833
+ }
834
+ };
838
835
 
839
- const resolvedWorkflows =
840
- typeof options.workflows === "function"
841
- ? await options.workflows(bindings)
842
- : options.workflows;
836
+ /**
837
+ * Check whether a value is an already-created instance (has an `id` or `name` property)
838
+ * rather than a factory function.
839
+ */
840
+ const isInstance = (v: unknown): boolean =>
841
+ typeof v === "object" &&
842
+ v !== null &&
843
+ ("id" in v || "name" in v || "uri" in v);
843
844
 
844
- tools.push(
845
- ...toolsFor<TSchema>({ ...options, workflows: resolvedWorkflows }),
846
- );
845
+ /**
846
+ * Resolve an array that may contain both direct instances and factory functions.
847
+ * Factories are called with `bindings` and trigger a deprecation warning.
848
+ */
849
+ async function resolveArray<T>(
850
+ items: Array<unknown> | undefined,
851
+ bindings: TEnv,
852
+ ): Promise<T[]> {
853
+ if (!items) return [];
854
+ return (
855
+ await Promise.all(
856
+ items.flatMap(async (item) => {
857
+ if (isInstance(item)) {
858
+ return [item as T];
859
+ }
860
+ // Factory function — deprecated path
861
+ warnFactoryDeprecation();
862
+ const result = await (item as (env: TEnv) => unknown)(bindings);
863
+ if (Array.isArray(result)) return result as T[];
864
+ return [result as T];
865
+ }),
866
+ )
867
+ ).flat();
868
+ }
869
+
870
+ const resolveRegistrations = async (
871
+ bindings: TEnv,
872
+ ): Promise<Registrations> => {
873
+ if (cached) return cached;
874
+ if (inflightResolve) return inflightResolve;
875
+
876
+ inflightResolve = (async (): Promise<Registrations> => {
877
+ try {
878
+ let tools: CreatedTool[];
879
+ if (typeof options.tools === "function") {
880
+ warnFactoryDeprecation();
881
+ tools = await options.tools(bindings);
882
+ } else {
883
+ tools = await resolveArray<CreatedTool>(options.tools, bindings);
884
+ }
885
+
886
+ const resolvedWorkflows =
887
+ typeof options.workflows === "function"
888
+ ? await options.workflows(bindings)
889
+ : options.workflows;
890
+
891
+ tools.push(
892
+ ...toolsFor<TSchema>({ ...options, workflows: resolvedWorkflows }),
893
+ );
894
+
895
+ let prompts: CreatedPrompt[];
896
+ if (typeof options.prompts === "function") {
897
+ warnFactoryDeprecation();
898
+ prompts = await options.prompts(bindings);
899
+ } else {
900
+ prompts = await resolveArray<CreatedPrompt>(
901
+ options.prompts,
902
+ bindings,
903
+ );
904
+ }
905
+
906
+ let resources: CreatedResource[];
907
+ if (typeof options.resources === "function") {
908
+ warnFactoryDeprecation();
909
+ resources = await options.resources(bindings);
910
+ } else {
911
+ resources = await resolveArray<CreatedResource>(
912
+ options.resources,
913
+ bindings,
914
+ );
915
+ }
916
+
917
+ const result = {
918
+ tools,
919
+ prompts,
920
+ resources,
921
+ workflows: resolvedWorkflows,
922
+ };
923
+ cached = result;
924
+ return result;
925
+ } catch (err) {
926
+ inflightResolve = null;
927
+ throw err;
928
+ }
929
+ })();
847
930
 
848
- for (const tool of tools) {
931
+ return inflightResolve;
932
+ };
933
+
934
+ const registerAll = (server: McpServer, registrations: Registrations) => {
935
+ for (const tool of registrations.tools) {
849
936
  server.registerTool(
850
937
  tool.id,
851
938
  {
852
- _meta: {
853
- streamable: isStreamableTool(tool),
854
- ...(tool._meta ?? {}),
855
- },
939
+ _meta: tool._meta,
856
940
  description: tool.description,
857
941
  annotations: tool.annotations,
858
942
  inputSchema:
859
943
  tool.inputSchema && "shape" in tool.inputSchema
860
944
  ? (tool.inputSchema.shape as ZodRawShape)
861
945
  : z.object({}).shape,
862
- outputSchema: isStreamableTool(tool)
863
- ? z.object({ bytes: z.record(z.string(), z.number()) }).shape
864
- : tool.outputSchema &&
865
- typeof tool.outputSchema === "object" &&
866
- "shape" in tool.outputSchema
946
+ outputSchema:
947
+ tool.outputSchema &&
948
+ typeof tool.outputSchema === "object" &&
949
+ "shape" in tool.outputSchema
867
950
  ? (tool.outputSchema.shape as ZodRawShape)
868
951
  : undefined,
869
952
  },
870
953
  async (args) => {
871
- const result = await tool.execute({
872
- context: args,
873
- runtimeContext: createRuntimeContext(),
874
- });
954
+ const ctx = createRuntimeContext();
955
+ const result = await tool.execute(
956
+ { context: args, runtimeContext: ctx },
957
+ ctx,
958
+ );
875
959
 
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)
879
- if (isStreamableTool(tool) && result instanceof Response) {
880
- return {
881
- structuredContent: {
882
- streamable: true,
883
- status: result.status,
884
- statusText: result.statusText,
885
- },
886
- content: [
887
- {
888
- type: "text",
889
- text: `Streaming response: ${result.status} ${result.statusText}`,
890
- },
891
- ],
892
- };
893
- }
894
960
  return {
895
961
  structuredContent: result as Record<string, unknown>,
896
962
  content: [
@@ -904,28 +970,7 @@ export const createMCPServer = <
904
970
  );
905
971
  }
906
972
 
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) {
973
+ for (const prompt of registrations.prompts) {
929
974
  server.registerPrompt(
930
975
  prompt.name,
931
976
  {
@@ -936,36 +981,19 @@ export const createMCPServer = <
936
981
  : z.object({}).shape,
937
982
  },
938
983
  async (args) => {
939
- return await prompt.execute({
940
- args: args as Record<string, string | undefined>,
941
- runtimeContext: createRuntimeContext(),
942
- });
984
+ const ctx = createRuntimeContext();
985
+ return await prompt.execute(
986
+ {
987
+ args: args as Record<string, string | undefined>,
988
+ runtimeContext: ctx,
989
+ },
990
+ ctx,
991
+ );
943
992
  },
944
993
  );
945
994
  }
946
995
 
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) {
996
+ for (const resource of registrations.resources) {
969
997
  server.resource(
970
998
  resource.name,
971
999
  resource.uri,
@@ -974,23 +1002,9 @@ export const createMCPServer = <
974
1002
  mimeType: resource.mimeType,
975
1003
  },
976
1004
  async (uri) => {
977
- const result = await resource.read({
978
- uri,
979
- runtimeContext: createRuntimeContext(),
980
- });
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
- }
1005
+ const ctx = createRuntimeContext();
1006
+ const result = await resource.read({ uri, runtimeContext: ctx }, ctx);
992
1007
 
993
- // MCP SDK expects either text or blob content, not both
994
1008
  const meta =
995
1009
  (result as { _meta?: Record<string, unknown> | null })._meta ??
996
1010
  undefined;
@@ -1018,7 +1032,6 @@ export const createMCPServer = <
1018
1032
  };
1019
1033
  }
1020
1034
 
1021
- // Fallback to empty text if neither provided
1022
1035
  return {
1023
1036
  contents: [
1024
1037
  { uri: result.uri, mimeType: result.mimeType, text: "" },
@@ -1027,8 +1040,28 @@ export const createMCPServer = <
1027
1040
  },
1028
1041
  );
1029
1042
  }
1043
+ };
1044
+
1045
+ const createServer = async (bindings: TEnv) => {
1046
+ await options.before?.(bindings);
1047
+
1048
+ const { instructions, ...serverInfoOverrides } = options.serverInfo ?? {};
1049
+ const server = new McpServer(
1050
+ {
1051
+ ...serverInfoOverrides,
1052
+ name: serverInfoOverrides.name ?? "@deco/mcp-api",
1053
+ version: serverInfoOverrides.version ?? "1.0.0",
1054
+ },
1055
+ {
1056
+ capabilities: { tools: {}, prompts: {}, resources: {} },
1057
+ ...(instructions && { instructions }),
1058
+ },
1059
+ );
1060
+
1061
+ const registrations = await resolveRegistrations(bindings);
1062
+ registerAll(server, registrations);
1030
1063
 
1031
- return { server, tools, prompts, resources };
1064
+ return { server, ...registrations };
1032
1065
  };
1033
1066
 
1034
1067
  const fetch = async (req: Request, env: TEnv) => {
@@ -1037,34 +1070,48 @@ export const createMCPServer = <
1037
1070
 
1038
1071
  await server.connect(transport);
1039
1072
 
1073
+ const cleanup = () => {
1074
+ try {
1075
+ transport.close?.();
1076
+ } catch {
1077
+ /* ignore */
1078
+ }
1079
+ try {
1080
+ server.close?.();
1081
+ } catch {
1082
+ /* ignore */
1083
+ }
1084
+ };
1085
+
1040
1086
  try {
1041
1087
  const response = await transport.handleRequest(req);
1042
1088
 
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
1089
  const contentType = response.headers.get("content-type");
1047
1090
  const isStreaming =
1048
1091
  contentType?.includes("text/event-stream") ||
1049
1092
  contentType?.includes("application/json-rpc");
1050
1093
 
1051
- // Only close transport for non-streaming responses
1052
- if (!isStreaming) {
1053
- try {
1054
- await transport.close?.();
1055
- } catch {
1056
- // Ignore close errors
1057
- }
1094
+ if (!isStreaming || !response.body) {
1095
+ cleanup();
1096
+ return response;
1058
1097
  }
1059
1098
 
1060
- return response;
1099
+ // Pipe the SSE body through a passthrough so that when the stream
1100
+ // finishes (server sent the response) or the client disconnects
1101
+ // (cancel), the server and transport are always cleaned up.
1102
+ const { readable, writable } = new TransformStream();
1103
+ response.body
1104
+ .pipeTo(writable)
1105
+ .catch(() => {})
1106
+ .finally(cleanup);
1107
+
1108
+ return new Response(readable, {
1109
+ status: response.status,
1110
+ statusText: response.statusText,
1111
+ headers: response.headers,
1112
+ });
1061
1113
  } 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
- }
1114
+ cleanup();
1068
1115
  throw error;
1069
1116
  }
1070
1117
  };
@@ -1075,7 +1122,9 @@ export const createMCPServer = <
1075
1122
  throw new Error("Missing state, did you forget to call State.bind?");
1076
1123
  }
1077
1124
  const env = currentState?.env;
1078
- const { tools } = await createServer(env as TEnv & DefaultEnv<TSchema>);
1125
+ const { tools } = await resolveRegistrations(
1126
+ env as TEnv & DefaultEnv<TSchema>,
1127
+ );
1079
1128
  const tool = tools.find((t) => t.id === toolCallId);
1080
1129
  const execute = tool?.execute;
1081
1130
  if (!execute) {
@@ -1084,10 +1133,8 @@ export const createMCPServer = <
1084
1133
  );
1085
1134
  }
1086
1135
 
1087
- return execute({
1088
- context: toolCallInput,
1089
- runtimeContext: createRuntimeContext(),
1090
- });
1136
+ const ctx = createRuntimeContext();
1137
+ return execute({ context: toolCallInput, runtimeContext: ctx }, ctx);
1091
1138
  };
1092
1139
 
1093
1140
  return {
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;