@decocms/runtime 1.5.0 → 1.6.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/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.5.0",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
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
@@ -7,14 +7,16 @@ import {
7
7
  } from "@decocms/bindings";
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { WebStandardStreamableHTTPServerTransport as HttpServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
10
- import type {
11
- CallToolResult,
12
- GetPromptResult,
13
- Implementation,
14
- ToolAnnotations,
10
+ import {
11
+ ListToolsRequestSchema,
12
+ type CallToolResult,
13
+ type GetPromptResult,
14
+ type Implementation,
15
+ type ListToolsResult,
16
+ type ToolAnnotations,
15
17
  } from "@modelcontextprotocol/sdk/types.js";
16
18
  import { z } from "zod";
17
- import type { ZodRawShape, ZodSchema, ZodTypeAny } from "zod";
19
+ import type { ZodSchema, ZodTypeAny } from "zod";
18
20
  import { BindingRegistry, injectBindingSchemas } from "./bindings.ts";
19
21
  import { Event, type EventHandlers } from "./events.ts";
20
22
  import type { DefaultEnv, User } from "./index.ts";
@@ -823,6 +825,12 @@ export const createMCPServer = <
823
825
  let cached: Registrations | null = null;
824
826
  let inflightResolve: Promise<Registrations> | null = null;
825
827
 
828
+ // The MCP SDK's `tools/list` handler runs `toJsonSchemaCompat()` for every
829
+ // registered tool on every request. For MCPs with hundreds of tools that
830
+ // dominates per-request latency (seconds, not ms). Cache the rendered
831
+ // payload across requests within the isolate.
832
+ let cachedListToolsResult: ListToolsResult | null = null;
833
+
826
834
  let _warnedFactoryDeprecation = false;
827
835
  const warnFactoryDeprecation = () => {
828
836
  if (!_warnedFactoryDeprecation) {
@@ -940,15 +948,19 @@ export const createMCPServer = <
940
948
  _meta: tool._meta,
941
949
  description: tool.description,
942
950
  annotations: tool.annotations,
951
+ // Pass the full ZodObject (not its `.shape`) so the SDK skips
952
+ // `objectFromShape(...)` (a fresh `z.object(shape)` per tool) inside
953
+ // `_createRegisteredTool`. The SDK's `getZodSchemaObject` returns
954
+ // an already-built object as-is.
943
955
  inputSchema:
944
956
  tool.inputSchema && "shape" in tool.inputSchema
945
- ? (tool.inputSchema.shape as ZodRawShape)
946
- : z.object({}).shape,
957
+ ? (tool.inputSchema as ZodTypeAny)
958
+ : z.object({}),
947
959
  outputSchema:
948
960
  tool.outputSchema &&
949
961
  typeof tool.outputSchema === "object" &&
950
962
  "shape" in tool.outputSchema
951
- ? (tool.outputSchema.shape as ZodRawShape)
963
+ ? (tool.outputSchema as ZodTypeAny)
952
964
  : undefined,
953
965
  },
954
966
  async (args) => {
@@ -1078,6 +1090,38 @@ export const createMCPServer = <
1078
1090
  const registrations = await resolveRegistrations(bindings);
1079
1091
  registerAll(server, registrations);
1080
1092
 
1093
+ // Wrap the SDK-installed `tools/list` handler so the rendered payload is
1094
+ // computed once per isolate and reused across requests. The MCP Server
1095
+ // itself can't be shared across requests (its transport is single-use, see
1096
+ // `Protocol.connect`), so each request still spins up a fresh Server +
1097
+ // Transport — but the listTools render is by far the dominant cost for
1098
+ // large tool surfaces, and it's pure of request-scoped state.
1099
+ const innerHandlers = (
1100
+ server.server as unknown as {
1101
+ _requestHandlers: Map<
1102
+ string,
1103
+ (req: unknown, extra: unknown) => Promise<unknown>
1104
+ >;
1105
+ }
1106
+ )._requestHandlers;
1107
+ const sdkListToolsHandler = innerHandlers.get(
1108
+ ListToolsRequestSchema.shape.method.value,
1109
+ );
1110
+ if (sdkListToolsHandler) {
1111
+ innerHandlers.set(
1112
+ ListToolsRequestSchema.shape.method.value,
1113
+ async (req, extra) => {
1114
+ if (!cachedListToolsResult) {
1115
+ cachedListToolsResult = (await sdkListToolsHandler(
1116
+ req,
1117
+ extra,
1118
+ )) as ListToolsResult;
1119
+ }
1120
+ return cachedListToolsResult;
1121
+ },
1122
+ );
1123
+ }
1124
+
1081
1125
  return { server, ...registrations };
1082
1126
  };
1083
1127
 
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: