@questpie/mcp 3.15.2 → 3.17.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.
@@ -0,0 +1,350 @@
1
+ import { C as validateMcpPrincipal, d as getAppMcpRelease, m as assertBoundedMcpValue, t as createMcpServer } from "./create-server-CEWFf041.mjs";
2
+ import { t as mcpPlugin } from "./plugin-BZQe1pji.mjs";
3
+ import { route } from "questpie";
4
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5
+
6
+ //#region src/server/oauth-scope-catalog.ts
7
+ const mcpOAuthScopeCatalog = (contributorApp) => {
8
+ const oauth = getAppMcpRelease(contributorApp).catalog.oauth;
9
+ return {
10
+ scopes: [...oauth.scopes],
11
+ scopesSupported: [...oauth.scopesSupported]
12
+ };
13
+ };
14
+
15
+ //#endregion
16
+ //#region src/server/mcp-http.ts
17
+ /**
18
+ * Shared MCP Streamable-HTTP handler.
19
+ *
20
+ * `route()` is single-method, so the MCP endpoint's four verbs (POST = JSON-RPC
21
+ * messages, GET/DELETE = explicit stateless 405, OPTIONS = CORS preflight) are
22
+ * registered as separate method-suffixed route files under `routes/`
23
+ * (`mcp.ts` = POST, `mcp.get.ts`, `mcp.delete.ts`, `mcp.options.ts`) per the
24
+ * codegen file convention — NOT method chaining and NOT a hand-written
25
+ * `module.ts`. All four share THIS handler, which dispatches on
26
+ * `request.method` internally because the transport needs one object for every
27
+ * verb on the path. Stateful sessions and reconnect semantics are intentionally
28
+ * absent.
29
+ */
30
+ const MAX_MCP_HTTP_BODY_BYTES = 1024 * 1024;
31
+ const MAX_MCP_HTTP_BATCH_SIZE = 16;
32
+ const MAX_MCP_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024;
33
+ const CORS_ALLOWED_HEADERS = new Set([
34
+ "authorization",
35
+ "content-type",
36
+ "mcp-protocol-version",
37
+ "x-api-key"
38
+ ]);
39
+ const mcpMeta = {
40
+ title: "MCP endpoint",
41
+ description: "Model Context Protocol endpoint for QUESTPIE."
42
+ };
43
+ function appFromRouteContext(ctx) {
44
+ return ctx.app;
45
+ }
46
+ function executionContextFromRouteContext(ctx) {
47
+ const routeCtx = { ...ctx };
48
+ delete routeCtx.app;
49
+ delete routeCtx.request;
50
+ return routeCtx;
51
+ }
52
+ function withCors(response, allowedOrigin$1) {
53
+ const headers = new Headers(response.headers);
54
+ if (allowedOrigin$1) {
55
+ headers.set("Access-Control-Allow-Origin", allowedOrigin$1);
56
+ headers.set("Vary", "Origin");
57
+ }
58
+ headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
59
+ headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, MCP-Protocol-Version, x-api-key");
60
+ return new Response(response.body, {
61
+ status: response.status,
62
+ statusText: response.statusText,
63
+ headers
64
+ });
65
+ }
66
+ function allowedOrigin(appUrl, request, additions) {
67
+ const requested = request.headers.get("origin");
68
+ if (!requested) return void 0;
69
+ const origins = new Set([new URL(appUrl).origin]);
70
+ for (const addition of additions ?? []) {
71
+ const origin = canonicalConfiguredOrigin(addition);
72
+ if (origin) origins.add(origin);
73
+ }
74
+ const canonicalRequested = canonicalConfiguredOrigin(requested);
75
+ return canonicalRequested && origins.has(canonicalRequested) ? canonicalRequested : null;
76
+ }
77
+ function canonicalConfiguredOrigin(value) {
78
+ try {
79
+ const url = new URL(value);
80
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash || value !== url.origin) return null;
81
+ return url.origin;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ function canonicalHost(value, protocol) {
87
+ if (!value || value.trim() !== value || value.includes("://") || /[/?#@\\]/.test(value)) return null;
88
+ try {
89
+ const url = new URL(`${protocol}//${value}`);
90
+ if (url.username || url.password || url.pathname !== "/" || !url.host) return null;
91
+ return url.host.toLowerCase();
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+ function hasAllowedHost(appUrl, request, additions) {
97
+ const app = new URL(appUrl);
98
+ const allowed = new Set([app.host.toLowerCase()]);
99
+ for (const addition of additions ?? []) {
100
+ const host = canonicalHost(addition, app.protocol);
101
+ if (host) allowed.add(host);
102
+ }
103
+ const requested = canonicalHost(request.headers.get("host") ?? new URL(request.url).host, app.protocol);
104
+ return requested !== null && allowed.has(requested);
105
+ }
106
+ function isAllowedPreflight(request) {
107
+ if (!request.headers.has("origin")) return false;
108
+ if (request.headers.get("access-control-request-method")?.toUpperCase() !== "POST") return false;
109
+ const requestedHeaders = request.headers.get("access-control-request-headers");
110
+ if (!requestedHeaders) return true;
111
+ const headers = requestedHeaders.split(",").map((header) => header.trim().toLowerCase());
112
+ return headers.length <= CORS_ALLOWED_HEADERS.size && headers.every((header) => header.length > 0 && CORS_ALLOWED_HEADERS.has(header));
113
+ }
114
+ async function readBoundedBody(request) {
115
+ if (!request.body) return new Uint8Array(/* @__PURE__ */ new ArrayBuffer(0));
116
+ const reader = request.body.getReader();
117
+ const chunks = [];
118
+ let size = 0;
119
+ while (true) {
120
+ const { done, value } = await reader.read();
121
+ if (done) break;
122
+ size += value.byteLength;
123
+ if (size > MAX_MCP_HTTP_BODY_BYTES) {
124
+ await reader.cancel();
125
+ return;
126
+ }
127
+ chunks.push(value);
128
+ }
129
+ const body = new Uint8Array(new ArrayBuffer(size));
130
+ let offset = 0;
131
+ for (const chunk of chunks) {
132
+ body.set(chunk, offset);
133
+ offset += chunk.byteLength;
134
+ }
135
+ return body;
136
+ }
137
+ function hasBoundedJsonRpcBatch(body, limits) {
138
+ try {
139
+ const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
140
+ assertBoundedMcpValue(payload, {
141
+ maxBytes: MAX_MCP_HTTP_BODY_BYTES,
142
+ maxValueDepth: limits.maxInputDepth,
143
+ maxValueNodes: limits.maxValueNodes
144
+ }, "input_too_large");
145
+ return !Array.isArray(payload) || payload.length > 0 && payload.length <= MAX_MCP_HTTP_BATCH_SIZE;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+ async function finalizeResponse(response, close) {
151
+ if (!response.body) {
152
+ await close();
153
+ return response;
154
+ }
155
+ const reader = response.body.getReader();
156
+ const chunks = [];
157
+ let size = 0;
158
+ try {
159
+ while (true) {
160
+ const next = await reader.read();
161
+ if (next.done) break;
162
+ size += next.value.byteLength;
163
+ if (size > MAX_MCP_HTTP_RESPONSE_BYTES) {
164
+ await reader.cancel();
165
+ throw new Error("MCP response exceeded limit");
166
+ }
167
+ chunks.push(next.value);
168
+ }
169
+ } finally {
170
+ await close();
171
+ }
172
+ const body = new Uint8Array(size);
173
+ let offset = 0;
174
+ for (const chunk of chunks) {
175
+ body.set(chunk, offset);
176
+ offset += chunk.byteLength;
177
+ }
178
+ return new Response(body, {
179
+ status: response.status,
180
+ statusText: response.statusText,
181
+ headers: response.headers
182
+ });
183
+ }
184
+ /**
185
+ * Build the `WWW-Authenticate` challenge value for an unauthenticated HTTP MCP
186
+ * request. `resource_metadata` points at this app's Protected Resource metadata
187
+ * document (RFC 9728), served beside the MCP route by the core
188
+ * `oauth-protected-resource` route (MO4). An MCP client reads it to discover
189
+ * the authorization server and start the OAuth 2.1 flow.
190
+ *
191
+ * The public origin comes from `app.config.app.url`, while the mount path comes
192
+ * from the actual MCP request. This keeps the canonical external host used for
193
+ * token audience binding, and also preserves adapter base paths such as `/api`.
194
+ */
195
+ function protectedResourceChallenge(appUrl, requestUrl) {
196
+ const request = new URL(requestUrl);
197
+ request.pathname = request.pathname.replace(/\/+$/, "");
198
+ const metadataPath = new URL(".well-known/oauth-protected-resource", request).pathname;
199
+ const metadataUrl = new URL(appUrl);
200
+ metadataUrl.pathname = metadataPath;
201
+ metadataUrl.search = "";
202
+ metadataUrl.hash = "";
203
+ return `Bearer resource_metadata="${metadataUrl.href}"`;
204
+ }
205
+ /**
206
+ * A verified principal is required to reach MCP tool execution over HTTP. The
207
+ * request context resolves one for a cookie/bearer session (`kind: "user"`) or a
208
+ * valid OAuth access token (`kind: "oauth"`); an unauthenticated request has
209
+ * none. Read it structurally — the handler-args type does not surface it.
210
+ */
211
+ function hasVerifiedPrincipal(ctx) {
212
+ const principal = ctx.principal;
213
+ const validated = validateMcpPrincipal(principal);
214
+ return validated?.kind === "user" || validated?.kind === "oauth";
215
+ }
216
+ async function handleMcpRequest(ctx) {
217
+ const { request } = ctx;
218
+ const app = appFromRouteContext(ctx);
219
+ const release = getAppMcpRelease(app);
220
+ const config = release.config;
221
+ const corsOrigin = allowedOrigin(app.config.app.url, request, config.http?.allowedOrigins);
222
+ if (corsOrigin === null || !hasAllowedHost(app.config.app.url, request, config.http?.allowedHosts)) return new Response(null, { status: 403 });
223
+ if (request.method === "GET" || request.method === "DELETE") return new Response(null, {
224
+ status: 405,
225
+ headers: { Allow: "POST, OPTIONS" }
226
+ });
227
+ if (request.method === "OPTIONS") {
228
+ if (!isAllowedPreflight(request)) return new Response(null, { status: 403 });
229
+ return withCors(new Response(null, { status: 204 }), corsOrigin);
230
+ }
231
+ if (request.headers.has("mcp-session-id") || request.headers.has("last-event-id")) return new Response(null, { status: 400 });
232
+ const declaredLength = request.headers.get("content-length");
233
+ if (declaredLength) {
234
+ if (!/^\d+$/.test(declaredLength)) return new Response(null, { status: 400 });
235
+ if (Number(declaredLength) > MAX_MCP_HTTP_BODY_BYTES) return new Response(null, { status: 413 });
236
+ }
237
+ if (!hasVerifiedPrincipal(ctx)) return withCors(new Response(null, {
238
+ status: 401,
239
+ headers: { "WWW-Authenticate": protectedResourceChallenge(app.config.app.url, request.url) }
240
+ }), corsOrigin);
241
+ const body = await readBoundedBody(request);
242
+ if (!body) return withCors(new Response(null, { status: 413 }), corsOrigin);
243
+ if (!hasBoundedJsonRpcBatch(body, release.execution.limits)) return withCors(new Response(null, { status: 400 }), corsOrigin);
244
+ const boundedRequest = new Request(request.url, {
245
+ method: request.method,
246
+ headers: request.headers,
247
+ body,
248
+ signal: request.signal
249
+ });
250
+ let server;
251
+ try {
252
+ server = await createMcpServer(app, {
253
+ transport: "http",
254
+ accessMode: "user",
255
+ request: boundedRequest,
256
+ ctx: executionContextFromRouteContext(ctx)
257
+ });
258
+ const appUrl = new URL(app.config.app.url);
259
+ const transport = new WebStandardStreamableHTTPServerTransport({
260
+ sessionIdGenerator: void 0,
261
+ allowedOrigins: [...new Set([appUrl.origin, ...(config.http?.allowedOrigins ?? []).map(canonicalConfiguredOrigin).filter((origin) => origin !== null)])],
262
+ allowedHosts: [...new Set([appUrl.host.toLowerCase(), ...(config.http?.allowedHosts ?? []).map((host) => canonicalHost(host, appUrl.protocol)).filter((host) => host !== null)])],
263
+ enableJsonResponse: config.http?.enableJsonResponse ?? true
264
+ });
265
+ await server.connect(transport);
266
+ return withCors(await finalizeResponse(await transport.handleRequest(boundedRequest), () => server.close()), corsOrigin);
267
+ } catch {
268
+ await server?.close().catch(() => void 0);
269
+ return withCors(new Response(null, { status: 500 }), corsOrigin);
270
+ }
271
+ }
272
+ async function mcpHandler(ctx) {
273
+ try {
274
+ return await handleMcpRequest(ctx);
275
+ } catch {
276
+ return new Response(null, { status: 500 });
277
+ }
278
+ }
279
+
280
+ //#endregion
281
+ //#region src/server/modules/mcp/routes/mcp.ts
282
+ var mcp_default$1 = route().post().raw().access(true).meta(mcpMeta).handler(mcpHandler);
283
+
284
+ //#endregion
285
+ //#region src/server/modules/mcp/routes/mcp.delete.ts
286
+ var mcp_delete_default = route().delete().raw().access(true).meta(mcpMeta).handler(mcpHandler);
287
+
288
+ //#endregion
289
+ //#region src/server/modules/mcp/routes/mcp.get.ts
290
+ var mcp_get_default = route().get().raw().access(true).meta(mcpMeta).handler(mcpHandler);
291
+
292
+ //#endregion
293
+ //#region src/server/modules/mcp/routes/mcp.options.ts
294
+ var mcp_options_default = route().options().raw().access(true).meta(mcpMeta).handler(mcpHandler);
295
+
296
+ //#endregion
297
+ //#region src/server/modules/mcp/plugin.ts
298
+ /**
299
+ * MCP module plugin — re-exports the MCP codegen plugin so that module
300
+ * codegen discovers it and embeds it in the generated module definition.
301
+ * This allows `extractPluginsFromModules` to pick it up automatically at
302
+ * root-app codegen time (so consuming apps get the `mcp-tools` category,
303
+ * `config/mcp.ts` discover, and scaffolds).
304
+ */
305
+ var plugin_default = mcpPlugin();
306
+
307
+ //#endregion
308
+ //#region src/server/modules/mcp/.generated/module.ts
309
+ const _module = {
310
+ name: "questpie-mcp",
311
+ routes: {
312
+ mcp: mcp_default$1,
313
+ "mcp:DELETE": mcp_delete_default,
314
+ "mcp:GET": mcp_get_default,
315
+ "mcp:OPTIONS": mcp_options_default
316
+ },
317
+ collections: {},
318
+ channels: {},
319
+ globals: {},
320
+ jobs: {},
321
+ messages: {},
322
+ services: {},
323
+ emails: {},
324
+ migrations: [],
325
+ seeds: [],
326
+ fieldTypes: {},
327
+ mcpTools: {},
328
+ plugin: plugin_default
329
+ };
330
+ var module_default = _module;
331
+
332
+ //#endregion
333
+ //#region src/server/modules/mcp/index.ts
334
+ /**
335
+ * MCP Module
336
+ *
337
+ * Backend module for the QUESTPIE Model Context Protocol endpoint.
338
+ *
339
+ * Exports:
340
+ * - `mcpModule` — static module definition (generated by codegen from file convention)
341
+ * - `McpModule` — type of the static module
342
+ */
343
+ const mcpModule = {
344
+ ...module_default,
345
+ oauthScopeCatalogs: { mcp: mcpOAuthScopeCatalog }
346
+ };
347
+ var mcp_default = mcpModule;
348
+
349
+ //#endregion
350
+ export { mcp_default as n, mcpModule as t };
@@ -1,2 +1,2 @@
1
- import { n as McpModule, t as mcpModule } from "../index-Cxta5KOn.mjs";
1
+ import { n as mcpModule, t as McpModule } from "../index-DlBFWTIg.mjs";
2
2
  export { type McpModule, mcpModule };
@@ -1,3 +1,3 @@
1
- import { t as mcpModule } from "../mcp-snyVygDQ.mjs";
1
+ import { t as mcpModule } from "../mcp-CuJEhynp.mjs";
2
2
 
3
3
  export { mcpModule };
@@ -29,7 +29,7 @@ function mcpPlugin() {
29
29
  scaffolds: { "mcp-tool": {
30
30
  dir: "mcp-tools",
31
31
  description: "MCP custom tool definition",
32
- template: ({ kebab, camel }) => `import { mcpTool } from "@questpie/mcp";\nimport { z } from "zod";\n\nexport default mcpTool("${kebab}", {\n\tdescription: "Run ${kebab}.",\n\tinputSchema: z.object({}),\n}).handler(async ({ ctx }) => {\n\treturn {\n\t\tstructuredContent: { ok: true },\n\t\tcontent: [{ type: "text", text: "${camel} completed" }],\n\t};\n});\n`
32
+ template: ({ kebab, camel }) => `import { mcpTool } from "@questpie/mcp";\nimport { z } from "zod";\n\nexport default mcpTool("${kebab}", {\n\taccess: ({ session }) => !!session,\n\tscopes: false,\n\tdescription: "Run ${kebab}.",\n\tinputSchema: z.object({}),\n}).handler(async ({ ctx }) => {\n\treturn {\n\t\tstructuredContent: { ok: true },\n\t\tcontent: [{ type: "text", text: "${camel} completed" }],\n\t};\n});\n`
33
33
  } }
34
34
  } }
35
35
  };
package/dist/plugin.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as mcpPlugin } from "./plugin-BJ6gELRj.mjs";
1
+ import { t as mcpPlugin } from "./plugin-BZQe1pji.mjs";
2
2
 
3
3
  export { mcpPlugin };
@@ -1,4 +1,4 @@
1
- import { t as createMcpServer } from "./create-server-DIvtVlma.mjs";
1
+ import { t as createMcpServer } from "./create-server-CEWFf041.mjs";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
 
4
4
  //#region src/server/stdio.ts