@questpie/mcp 3.16.0 → 3.18.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 +150 -6
- package/dist/create-server-CEWFf041.mjs +2394 -0
- package/dist/{index-Bt_uk798.d.mts → index-DlBFWTIg.d.mts} +28 -23
- package/dist/index.d.mts +91 -47
- package/dist/index.mjs +92 -5
- package/dist/mcp-CuJEhynp.mjs +350 -0
- package/dist/modules/mcp.d.mts +1 -1
- package/dist/modules/mcp.mjs +1 -1
- package/dist/{plugin-BJ6gELRj.mjs → plugin-BZQe1pji.mjs} +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/{stdio-qyQFVEyU.mjs → stdio-CgIPiPUb.mjs} +1 -1
- package/dist/stdio-YoM70-42.d.mts +323 -0
- package/dist/stdio.d.mts +1 -1
- package/dist/stdio.mjs +1 -1
- package/package.json +5 -4
- package/dist/create-server-DIvtVlma.mjs +0 -1014
- package/dist/mcp-snyVygDQ.mjs +0 -168
- package/dist/stdio-t_9rKayH.d.mts +0 -154
|
@@ -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 };
|
package/dist/modules/mcp.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as
|
|
1
|
+
import { n as mcpModule, t as McpModule } from "../index-DlBFWTIg.mjs";
|
|
2
2
|
export { type McpModule, mcpModule };
|
package/dist/modules/mcp.mjs
CHANGED
|
@@ -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