@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 +9 -26
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/oauth.test.ts +96 -0
- package/src/oauth.ts +47 -2
- package/src/tools.ts +53 -9
- package/src/triggers.ts +1 -1
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: [
|
|
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
|
-
|
|
157
|
+
Pass an array of tool instances created with `createTool()` / `createPrivateTool()`:
|
|
158
158
|
|
|
159
159
|
```typescript
|
|
160
160
|
export default withRuntime({
|
|
161
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
653
|
-
|
|
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
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
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 {
|
|
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
|
|
946
|
-
: z.object({})
|
|
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
|
|
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
|
|