@decocms/runtime 1.4.0 → 1.6.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
@@ -39,7 +39,7 @@ const greetTool = createTool({
39
39
 
40
40
  // Create the MCP server
41
41
  export default withRuntime({
42
- tools: [() => greetTool],
42
+ tools: [greetTool],
43
43
  });
44
44
  ```
45
45
 
@@ -154,21 +154,11 @@ const getUserDataTool = createPrivateTool({
154
154
 
155
155
  ### Registering Tools
156
156
 
157
- Tools can be registered in multiple ways:
157
+ Pass an array of tool instances created with `createTool()` / `createPrivateTool()`:
158
158
 
159
159
  ```typescript
160
160
  export default withRuntime({
161
- // Option 1: Array of tool factories
162
- tools: [
163
- () => greetTool,
164
- () => calculateTool,
165
- (env) => createDynamicTool(env),
166
- ],
167
-
168
- // Option 2: Single function returning array
169
- tools: async (env) => {
170
- return [greetTool, calculateTool];
171
- },
161
+ tools: [greetTool, calculateTool],
172
162
  });
173
163
  ```
174
164
 
@@ -358,10 +348,10 @@ export default withRuntime({
358
348
  },
359
349
 
360
350
  tools: [
361
- (env) => createTool({
351
+ createTool({
362
352
  id: "query",
363
353
  inputSchema: z.object({ sql: z.string() }),
364
- execute: async ({ runtimeContext }) => {
354
+ execute: async ({ context, runtimeContext }) => {
365
355
  // Access resolved bindings from state
366
356
  const { database } = runtimeContext.env.MESH_REQUEST_CONTEXT.state;
367
357
  return database.QUERY({ sql: context.sql });
@@ -451,7 +441,7 @@ const channelTools = impl(WellKnownBindings.Channel, [
451
441
  ]);
452
442
 
453
443
  export default withRuntime({
454
- tools: [() => channelTools].flat(),
444
+ tools: channelTools,
455
445
  });
456
446
  ```
457
447
 
@@ -648,16 +638,9 @@ const statusResource = createResource({
648
638
 
649
639
  // Export the MCP server
650
640
  export default withRuntime({
651
- tools: [
652
- () => echoTool,
653
- () => getProfileTool,
654
- ],
655
- prompts: [
656
- () => analyzePrompt,
657
- ],
658
- resources: [
659
- () => statusResource,
660
- ],
641
+ tools: [echoTool, getProfileTool],
642
+ prompts: [analyzePrompt],
643
+ resources: [statusResource],
661
644
  cors: {
662
645
  origin: "*",
663
646
  credentials: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
package/src/bindings.ts CHANGED
@@ -161,7 +161,10 @@ 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("auto"),
164
+ toolApprovalLevel: z.enum(["auto", "readonly"]).default("auto"),
165
+ mode: z
166
+ .enum(["default", "plan", "web-search", "gen-image"])
167
+ .default("default"),
165
168
  temperature: z.number().default(0.5),
166
169
  });
167
170
 
package/src/decopilot.ts CHANGED
@@ -31,7 +31,9 @@ export interface AgentBindingConfig {
31
31
  thinking?: AgentModelInfo;
32
32
  coding?: AgentModelInfo;
33
33
  fast?: AgentModelInfo;
34
- toolApprovalLevel?: "auto" | "readonly" | "plan";
34
+ toolApprovalLevel?: "auto" | "readonly";
35
+ /** Decopilot stream mode — default, plan, web-search, gen-image */
36
+ mode?: "default" | "plan" | "web-search" | "gen-image";
35
37
  temperature?: number;
36
38
  }
37
39
 
@@ -45,7 +47,9 @@ export interface AgentStreamParams {
45
47
  thinking?: AgentModelInfo;
46
48
  coding?: AgentModelInfo;
47
49
  fast?: AgentModelInfo;
48
- toolApprovalLevel?: "auto" | "readonly" | "plan";
50
+ toolApprovalLevel?: "auto" | "readonly";
51
+ /** Decopilot stream mode — default, plan, web-search, gen-image */
52
+ mode?: "default" | "plan" | "web-search" | "gen-image";
49
53
  temperature?: number;
50
54
  memory?: { windowSize: number; thread_id: string };
51
55
  thread_id?: string;
@@ -126,6 +130,7 @@ export async function streamAgent(
126
130
  agent: { id: agentId },
127
131
  temperature: params.temperature ?? config.temperature,
128
132
  toolApprovalLevel: params.toolApprovalLevel ?? config.toolApprovalLevel,
133
+ mode: params.mode ?? config.mode ?? "default",
129
134
  ...(params.memory ? { memory: params.memory } : {}),
130
135
  ...(params.thread_id ? { thread_id: params.thread_id } : {}),
131
136
  };
@@ -188,6 +193,7 @@ export function createDecopilotClient(options: DecopilotClientOptions) {
188
193
  coding: request.coding,
189
194
  fast: request.fast,
190
195
  toolApprovalLevel: request.toolApprovalLevel,
196
+ mode: request.mode,
191
197
  temperature: request.temperature,
192
198
  };
193
199
  return streamAgent(streamUrl, token, config, request, opts);
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from "./bindings.ts";
9
9
  import { type CORSOptions, handlePreflight, withCORS } from "./cors.ts";
10
10
  import { createOAuthHandlers } from "./oauth.ts";
11
+ export { OAuthInvalidGrantError } from "./oauth.ts";
11
12
  import { State } from "./state.ts";
12
13
  import {
13
14
  createMCPServer,
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { createOAuthHandlers, OAuthInvalidGrantError } from "./oauth.ts";
3
+ import type { OAuthConfig } from "./tools.ts";
4
+
5
+ const baseConfig = (
6
+ refreshToken?: OAuthConfig["refreshToken"],
7
+ ): OAuthConfig => ({
8
+ mode: "PKCE",
9
+ authorizationServer: "https://upstream.example.com",
10
+ authorizationUrl: () => "https://upstream.example.com/authorize",
11
+ exchangeCode: async () => ({
12
+ access_token: "at",
13
+ token_type: "Bearer",
14
+ }),
15
+ refreshToken,
16
+ });
17
+
18
+ const buildTokenRequest = (body: Record<string, string>) =>
19
+ new Request("https://mcp.example.com/token", {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
22
+ body: new URLSearchParams(body).toString(),
23
+ });
24
+
25
+ describe("OAuth /token refresh handler", () => {
26
+ it("returns 400 invalid_grant when refreshToken throws OAuthInvalidGrantError", async () => {
27
+ const handlers = createOAuthHandlers(
28
+ baseConfig(async () => {
29
+ throw new OAuthInvalidGrantError(
30
+ "invalid_grant",
31
+ "refresh token revoked",
32
+ );
33
+ }),
34
+ );
35
+
36
+ const response = await handlers.handleToken(
37
+ buildTokenRequest({
38
+ grant_type: "refresh_token",
39
+ refresh_token: "rt",
40
+ }),
41
+ );
42
+
43
+ expect(response.status).toBe(400);
44
+ const body = (await response.json()) as {
45
+ error: string;
46
+ error_description?: string;
47
+ };
48
+ expect(body.error).toBe("invalid_grant");
49
+ expect(body.error_description).toBe("refresh token revoked");
50
+ });
51
+
52
+ it("returns 500 server_error when refreshToken throws a generic error", async () => {
53
+ const handlers = createOAuthHandlers(
54
+ baseConfig(async () => {
55
+ throw new Error("upstream is down");
56
+ }),
57
+ );
58
+
59
+ const response = await handlers.handleToken(
60
+ buildTokenRequest({
61
+ grant_type: "refresh_token",
62
+ refresh_token: "rt",
63
+ }),
64
+ );
65
+
66
+ expect(response.status).toBe(500);
67
+ const body = (await response.json()) as { error: string };
68
+ expect(body.error).toBe("server_error");
69
+ });
70
+
71
+ it("forwards the new token on success", async () => {
72
+ const handlers = createOAuthHandlers(
73
+ baseConfig(async () => ({
74
+ access_token: "fresh",
75
+ token_type: "Bearer",
76
+ refresh_token: "rt2",
77
+ expires_in: 3600,
78
+ })),
79
+ );
80
+
81
+ const response = await handlers.handleToken(
82
+ buildTokenRequest({
83
+ grant_type: "refresh_token",
84
+ refresh_token: "rt",
85
+ }),
86
+ );
87
+
88
+ expect(response.status).toBe(200);
89
+ const body = (await response.json()) as {
90
+ access_token: string;
91
+ refresh_token?: string;
92
+ };
93
+ expect(body.access_token).toBe("fresh");
94
+ expect(body.refresh_token).toBe("rt2");
95
+ });
96
+ });
package/src/oauth.ts CHANGED
@@ -1,5 +1,28 @@
1
1
  import type { OAuthClient, OAuthConfig, OAuthParams } from "./tools.ts";
2
2
 
3
+ /**
4
+ * Thrown by `OAuthConfig.refreshToken` (or `exchangeCode`) implementations
5
+ * when the upstream OAuth provider says the grant itself is permanently
6
+ * invalid — e.g. GitHub returns `400 invalid_grant` because the user
7
+ * revoked the app or the refresh_token was rotated out from under us.
8
+ *
9
+ * The `/token` handler maps this to an RFC-6749-compliant
10
+ * `400 {"error":"invalid_grant",...}` response, so callers can tell apart
11
+ * "the user needs to reconnect" from a transient upstream 5xx (which the
12
+ * outer catch maps to a 500). Throwing a plain `Error` from `refreshToken`
13
+ * will be treated as transient and surface as 500.
14
+ */
15
+ export class OAuthInvalidGrantError extends Error {
16
+ readonly error: string;
17
+ readonly errorDescription?: string;
18
+ constructor(error = "invalid_grant", errorDescription?: string) {
19
+ super(errorDescription ?? error);
20
+ this.name = "OAuthInvalidGrantError";
21
+ this.error = error;
22
+ this.errorDescription = errorDescription;
23
+ }
24
+ }
25
+
3
26
  /**
4
27
  * Generate a cryptographically secure random token
5
28
  */
@@ -338,8 +361,30 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
338
361
  );
339
362
  }
340
363
 
341
- // Call the external provider to refresh the token
342
- const newTokenResponse = await oauth.refreshToken(refresh_token);
364
+ // Call the external provider to refresh the token. We catch
365
+ // `OAuthInvalidGrantError` here (not in the outer catch) so we can
366
+ // map it to a spec-compliant 400 instead of letting all errors fall
367
+ // through to a generic 500. Any other thrown error is treated as
368
+ // transient and surfaces from the outer catch as 500.
369
+ let newTokenResponse: Awaited<
370
+ ReturnType<NonNullable<OAuthConfig["refreshToken"]>>
371
+ >;
372
+ try {
373
+ newTokenResponse = await oauth.refreshToken(refresh_token);
374
+ } catch (err) {
375
+ if (err instanceof OAuthInvalidGrantError) {
376
+ return Response.json(
377
+ {
378
+ error: err.error,
379
+ ...(err.errorDescription
380
+ ? { error_description: err.errorDescription }
381
+ : {}),
382
+ },
383
+ { status: 400 },
384
+ );
385
+ }
386
+ throw err;
387
+ }
343
388
 
344
389
  const tokenResponse: Record<string, unknown> = {
345
390
  access_token: newTokenResponse.access_token,
package/src/tools.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  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
+ CallToolResult,
11
12
  GetPromptResult,
12
13
  Implementation,
13
14
  ToolAnnotations,
@@ -957,6 +958,22 @@ export const createMCPServer = <
957
958
  ctx,
958
959
  );
959
960
 
961
+ if (
962
+ result != null &&
963
+ typeof result === "object" &&
964
+ "content" in result &&
965
+ Array.isArray(result.content) &&
966
+ result.content.every(
967
+ (item: unknown) =>
968
+ item != null &&
969
+ typeof item === "object" &&
970
+ "type" in item &&
971
+ typeof (item as Record<string, unknown>).type === "string",
972
+ )
973
+ ) {
974
+ return result as CallToolResult;
975
+ }
976
+
960
977
  return {
961
978
  structuredContent: result as Record<string, unknown>,
962
979
  content: [
package/src/triggers.ts CHANGED
@@ -156,7 +156,7 @@ class TriggerStateManager {
156
156
  *
157
157
  * // In withRuntime:
158
158
  * export default withRuntime({
159
- * tools: [() => triggers.tools()],
159
+ * tools: triggers.tools(),
160
160
  * });
161
161
  *
162
162
  * // In webhook handler: