@ggui-ai/mcp-server 0.18.0 → 0.20.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/dist/oauth.d.ts +9 -7
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +146 -22
- package/package.json +13 -13
package/dist/oauth.d.ts
CHANGED
|
@@ -282,13 +282,14 @@ export declare function handleProtectedResourceMetadata(req: Request, res: Respo
|
|
|
282
282
|
* describing this auth server's capabilities + endpoints.
|
|
283
283
|
*/
|
|
284
284
|
export declare function handleAuthorizationServerMetadata(req: Request, res: Response, config: OAuthConfig): void;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
*/
|
|
285
|
+
type RedirectUriVerdict = {
|
|
286
|
+
readonly ok: readonly string[];
|
|
287
|
+
} | {
|
|
288
|
+
readonly error: 'invalid_redirect_uri' | 'invalid_client_metadata';
|
|
289
|
+
readonly description: string;
|
|
290
|
+
};
|
|
291
|
+
/** Every rule the door applies to `redirect_uris`; refuses the WHOLE list on the first bad entry. */
|
|
292
|
+
export declare function validateRedirectUris(raw: unknown): RedirectUriVerdict;
|
|
292
293
|
export declare function handleRegister(req: Request, res: Response, config: OAuthConfig, storage: OAuthStorage): Promise<void>;
|
|
293
294
|
/**
|
|
294
295
|
* `GET /oauth/authorize` — render the paste-key form. Query params
|
|
@@ -345,4 +346,5 @@ export declare function handleAuthorizePost(req: Request, res: Response, config:
|
|
|
345
346
|
* 400 status code.
|
|
346
347
|
*/
|
|
347
348
|
export declare function handleToken(req: Request, res: Response, storage: OAuthStorage): Promise<void>;
|
|
349
|
+
export {};
|
|
348
350
|
//# sourceMappingURL=oauth.d.ts.map
|
package/dist/oauth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
|
package/dist/oauth.js
CHANGED
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
* once per install + caches the `client_id`. A server restart drops all
|
|
61
61
|
* registrations; clients re-register transparently on next failure.
|
|
62
62
|
*/
|
|
63
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
63
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
64
64
|
import { isRecord } from '@ggui-ai/protocol';
|
|
65
65
|
import { resolveIdentityFromHeaders, UnauthenticatedError } from './auth.js';
|
|
66
66
|
export class InMemoryOAuthStorage {
|
|
@@ -181,8 +181,15 @@ function sha256Base64Url(input) {
|
|
|
181
181
|
return createHash('sha256').update(input).digest('base64url');
|
|
182
182
|
}
|
|
183
183
|
function verifyPkce(verifier, challenge) {
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
// Constant-time compare (ggui#1174). Both sides are base64url SHA-256
|
|
185
|
+
// digests when honest (43 chars); a length mismatch is refused before
|
|
186
|
+
// the compare because `timingSafeEqual` throws on unequal lengths, and
|
|
187
|
+
// the length of a digest is public anyway.
|
|
188
|
+
const computed = Buffer.from(sha256Base64Url(verifier));
|
|
189
|
+
const presented = Buffer.from(challenge);
|
|
190
|
+
if (computed.length !== presented.length)
|
|
191
|
+
return false;
|
|
192
|
+
return timingSafeEqual(computed, presented);
|
|
186
193
|
}
|
|
187
194
|
// =============================================================================
|
|
188
195
|
// Route handlers
|
|
@@ -230,31 +237,119 @@ export function handleAuthorizationServerMetadata(req, res, config) {
|
|
|
230
237
|
}
|
|
231
238
|
/**
|
|
232
239
|
* `POST /oauth/register` — RFC 7591 Dynamic Client Registration. Issues
|
|
233
|
-
* a random `client_id`. No `client_secret` (PKCE-only).
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
240
|
+
* a random `client_id`. No `client_secret` (PKCE-only). Registration is
|
|
241
|
+
* OPEN — there is no allowlist of redirect URIs, because the MCP
|
|
242
|
+
* authorization spec (2025-06-18) expects any host to register itself
|
|
243
|
+
* before its user has ever seen this server. What keeps an open door
|
|
244
|
+
* from being an open redirect (ggui#1174) is not the door but the
|
|
245
|
+
* controls around it, each of them pinned in `oauth-dcr.test.ts`:
|
|
246
|
+
*
|
|
247
|
+
* - `redirect_uris` are SHAPE-checked here ({@link validateRedirectUris}):
|
|
248
|
+
* absolute; no fragment; `https`, or `http` on a loopback host
|
|
249
|
+
* (RFC 8252 §7.3), or a private-use scheme (RFC 8252 §7.1 — the
|
|
250
|
+
* shape desktop MCP hosts register) that a browser cannot execute;
|
|
251
|
+
* no wildcard host; at most {@link MAX_REDIRECT_URIS}; one bad entry
|
|
252
|
+
* refuses the whole registration (`invalid_redirect_uri`).
|
|
253
|
+
* - `client_name` is bounded and printable (`invalid_client_metadata`)
|
|
254
|
+
* because the consent page prints it.
|
|
255
|
+
* - `/oauth/authorize` accepts only a redirect URI the client registered,
|
|
256
|
+
* by exact string match, and only PKCE `S256`.
|
|
257
|
+
* - `/oauth/token` binds the code to (client, redirect URI, challenge),
|
|
258
|
+
* single-use, 5-minute TTL, constant-time verifier check.
|
|
259
|
+
* - The consent page names the client and the redirect host BEFORE it
|
|
260
|
+
* asks for a key, so the user is the last control and an informed one.
|
|
237
261
|
*/
|
|
262
|
+
const MAX_REDIRECT_URIS = 10;
|
|
263
|
+
const MAX_REDIRECT_URI_LENGTH = 2048;
|
|
264
|
+
const MAX_CLIENT_NAME_LENGTH = 100;
|
|
265
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']);
|
|
266
|
+
/** Schemes a browser will EXECUTE rather than navigate to — never a redirect target. */
|
|
267
|
+
const EXECUTABLE_SCHEMES = new Set([
|
|
268
|
+
'javascript:',
|
|
269
|
+
'data:',
|
|
270
|
+
'blob:',
|
|
271
|
+
'file:',
|
|
272
|
+
'about:',
|
|
273
|
+
'vbscript:',
|
|
274
|
+
]);
|
|
275
|
+
// eslint-disable-next-line no-control-regex -- the point is to find control characters
|
|
276
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
277
|
+
/** The one reason a URI is refused, or `undefined` when it is a redirect target. */
|
|
278
|
+
function redirectUriProblem(uri) {
|
|
279
|
+
if (uri.length > MAX_REDIRECT_URI_LENGTH)
|
|
280
|
+
return 'longer than 2048 characters';
|
|
281
|
+
if (uri.includes('#'))
|
|
282
|
+
return 'carries a fragment';
|
|
283
|
+
let url;
|
|
284
|
+
try {
|
|
285
|
+
url = new URL(uri);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return 'is not an absolute URL';
|
|
289
|
+
}
|
|
290
|
+
if (url.hostname.includes('*'))
|
|
291
|
+
return 'names a wildcard host';
|
|
292
|
+
if (url.protocol === 'https:')
|
|
293
|
+
return undefined;
|
|
294
|
+
if (url.protocol === 'http:') {
|
|
295
|
+
return LOOPBACK_HOSTS.has(url.hostname)
|
|
296
|
+
? undefined
|
|
297
|
+
: 'uses `http` on a non-loopback host (only 127.0.0.1, [::1] and localhost may be plain http)';
|
|
298
|
+
}
|
|
299
|
+
if (EXECUTABLE_SCHEMES.has(url.protocol))
|
|
300
|
+
return `uses the \`${url.protocol}\` scheme`;
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
/** Every rule the door applies to `redirect_uris`; refuses the WHOLE list on the first bad entry. */
|
|
304
|
+
export function validateRedirectUris(raw) {
|
|
305
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
306
|
+
return { error: 'invalid_redirect_uri', description: '`redirect_uris` array is required' };
|
|
307
|
+
}
|
|
308
|
+
if (raw.length > MAX_REDIRECT_URIS) {
|
|
309
|
+
return {
|
|
310
|
+
error: 'invalid_client_metadata',
|
|
311
|
+
description: `\`redirect_uris\` carries ${raw.length} entries; at most ${MAX_REDIRECT_URIS} are accepted`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const entry of raw) {
|
|
316
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
317
|
+
return { error: 'invalid_redirect_uri', description: 'every `redirect_uris` entry must be a non-empty string' };
|
|
318
|
+
}
|
|
319
|
+
const problem = redirectUriProblem(entry);
|
|
320
|
+
if (problem !== undefined) {
|
|
321
|
+
return { error: 'invalid_redirect_uri', description: `redirect URI ${problem}: ${entry.slice(0, 200)}` };
|
|
322
|
+
}
|
|
323
|
+
out.push(entry);
|
|
324
|
+
}
|
|
325
|
+
return { ok: out };
|
|
326
|
+
}
|
|
238
327
|
export async function handleRegister(req, res, config, storage) {
|
|
239
328
|
const body = isRecord(req.body) ? req.body : {};
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
329
|
+
const verdict = validateRedirectUris(body['redirect_uris']);
|
|
330
|
+
if ('error' in verdict) {
|
|
331
|
+
res.status(400).json({ error: verdict.error, error_description: verdict.description });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const redirectUris = verdict.ok;
|
|
335
|
+
const clientNameRaw = body['client_name'];
|
|
336
|
+
if (clientNameRaw !== undefined && typeof clientNameRaw !== 'string') {
|
|
242
337
|
res.status(400).json({
|
|
243
|
-
error: '
|
|
244
|
-
error_description: '`
|
|
338
|
+
error: 'invalid_client_metadata',
|
|
339
|
+
error_description: '`client_name` must be a string when present',
|
|
245
340
|
});
|
|
246
341
|
return;
|
|
247
342
|
}
|
|
248
|
-
|
|
249
|
-
|
|
343
|
+
if (typeof clientNameRaw === 'string' &&
|
|
344
|
+
(clientNameRaw.length > MAX_CLIENT_NAME_LENGTH || CONTROL_CHARS.test(clientNameRaw))) {
|
|
250
345
|
res.status(400).json({
|
|
251
|
-
error: '
|
|
252
|
-
error_description:
|
|
346
|
+
error: 'invalid_client_metadata',
|
|
347
|
+
error_description: `\`client_name\` must be at most ${MAX_CLIENT_NAME_LENGTH} printable characters`,
|
|
253
348
|
});
|
|
254
349
|
return;
|
|
255
350
|
}
|
|
351
|
+
const clientName = clientNameRaw;
|
|
256
352
|
const clientId = `mcp_client_${randomBytes(16).toString('base64url')}`;
|
|
257
|
-
const clientName = typeof body['client_name'] === 'string' ? body['client_name'] : undefined;
|
|
258
353
|
await storage.putClient({
|
|
259
354
|
clientId,
|
|
260
355
|
redirectUris,
|
|
@@ -305,12 +400,19 @@ export async function handleAuthorizeGet(req, res, config, storage) {
|
|
|
305
400
|
target.searchParams.set(k, val);
|
|
306
401
|
}
|
|
307
402
|
target.searchParams.set('mcp_origin', resolveIssuerUrl(req, config.issuerUrl));
|
|
403
|
+
// ggui#1174 — the hosted consent page names the client too. `client_name`
|
|
404
|
+
// is DISPLAY-ONLY and self-asserted at DCR (a stranger chose it); the
|
|
405
|
+
// honest signal on any consent page is `redirect_uri`, which the server
|
|
406
|
+
// binds by exact match to what that client registered.
|
|
407
|
+
if (v.client.clientName !== undefined) {
|
|
408
|
+
target.searchParams.set('client_name', v.client.clientName);
|
|
409
|
+
}
|
|
308
410
|
res.redirect(302, target.toString());
|
|
309
411
|
return;
|
|
310
412
|
}
|
|
311
413
|
// Forward all params back to the form so POST handler has them.
|
|
312
414
|
// Includes `code_challenge`, `state`, etc.
|
|
313
|
-
res.type('html').send(renderAuthorizePage(params));
|
|
415
|
+
res.type('html').send(renderAuthorizePage(params, v.client));
|
|
314
416
|
}
|
|
315
417
|
/**
|
|
316
418
|
* `POST /oauth/authorize` — handle paste-key form submission. Validates
|
|
@@ -357,7 +459,7 @@ export async function handleAuthorizePost(req, res, config, storage, auth, pairi
|
|
|
357
459
|
res
|
|
358
460
|
.status(401)
|
|
359
461
|
.type('html')
|
|
360
|
-
.send(renderAuthorizePage(params, 'Pair code expired or invalid — restart the server for a fresh code.'));
|
|
462
|
+
.send(renderAuthorizePage(params, validation.client, 'Pair code expired or invalid — restart the server for a fresh code.'));
|
|
361
463
|
return;
|
|
362
464
|
}
|
|
363
465
|
}
|
|
@@ -365,7 +467,7 @@ export async function handleAuthorizePost(req, res, config, storage, auth, pairi
|
|
|
365
467
|
apiKey = pastedKey;
|
|
366
468
|
}
|
|
367
469
|
else {
|
|
368
|
-
res.status(400).type('html').send(renderAuthorizePage(params, 'Pair code or API key required'));
|
|
470
|
+
res.status(400).type('html').send(renderAuthorizePage(params, validation.client, 'Pair code or API key required'));
|
|
369
471
|
return;
|
|
370
472
|
}
|
|
371
473
|
// Validate the key by running the adapter's auth path. Using the
|
|
@@ -382,7 +484,7 @@ export async function handleAuthorizePost(req, res, config, storage, auth, pairi
|
|
|
382
484
|
res
|
|
383
485
|
.status(401)
|
|
384
486
|
.type('html')
|
|
385
|
-
.send(renderAuthorizePage(params, 'Invalid API key — try again.'));
|
|
487
|
+
.send(renderAuthorizePage(params, validation.client, 'Invalid API key — try again.'));
|
|
386
488
|
return;
|
|
387
489
|
}
|
|
388
490
|
throw err;
|
|
@@ -570,7 +672,7 @@ async function validateAuthorizeParams(params, storage, config, issuer) {
|
|
|
570
672
|
}
|
|
571
673
|
}
|
|
572
674
|
}
|
|
573
|
-
return { valid: true };
|
|
675
|
+
return { valid: true, client };
|
|
574
676
|
}
|
|
575
677
|
// =============================================================================
|
|
576
678
|
// HTML rendering
|
|
@@ -667,7 +769,28 @@ ${bodyHtml}
|
|
|
667
769
|
</body>
|
|
668
770
|
</html>`;
|
|
669
771
|
}
|
|
670
|
-
|
|
772
|
+
/** What the consent page prints as the redirect target: the host when the URI has one, else the scheme. */
|
|
773
|
+
function describeRedirectTarget(uri) {
|
|
774
|
+
if (!uri)
|
|
775
|
+
return 'an unknown destination';
|
|
776
|
+
try {
|
|
777
|
+
const url = new URL(uri);
|
|
778
|
+
return url.host.length > 0 ? url.host : `a \`${url.protocol}\` app`;
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
return 'an unknown destination';
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function renderAuthorizePage(params, client, errorMessage) {
|
|
785
|
+
// ggui#1174 — the user is the last control on an OPEN registration
|
|
786
|
+
// door, so the page says WHO registered (the DCR client_name, which a
|
|
787
|
+
// stranger chose — escaped, never trusted) and WHERE the code will be
|
|
788
|
+
// sent, before it asks for a key.
|
|
789
|
+
const who = client.clientName
|
|
790
|
+
? `<strong>${escapeHtml(client.clientName)}</strong> <span class="ggui-code">${escapeHtml(client.clientId)}</span>`
|
|
791
|
+
: `an unnamed client <span class="ggui-code">${escapeHtml(client.clientId)}</span>`;
|
|
792
|
+
const whereTo = escapeHtml(describeRedirectTarget(params['redirect_uri']));
|
|
793
|
+
const requester = `<p class="ggui-muted">${who} is asking to connect. If you authorize, a one-time code is sent to <strong>${whereTo}</strong>. If you do not recognise either, close this page.</p>`;
|
|
671
794
|
// Forward every OAuth param as a hidden input so the POST handler
|
|
672
795
|
// sees the same shape as /GET — no client-side state stash needed.
|
|
673
796
|
const hiddenFields = [
|
|
@@ -691,6 +814,7 @@ function renderAuthorizePage(params, errorMessage) {
|
|
|
691
814
|
: '';
|
|
692
815
|
const body = `<div class="ggui-card"><form method="POST" action="/oauth/authorize" class="ggui-stack">
|
|
693
816
|
${errorCallout}
|
|
817
|
+
${requester}
|
|
694
818
|
<label for="pair_code" class="ggui-label">Pair code</label>
|
|
695
819
|
<div class="ggui-field"><input type="text" id="pair_code" name="pair_code" placeholder="000000" inputmode="numeric" autocomplete="off" autofocus pattern="[0-9]{6}" maxlength="6"></div>
|
|
696
820
|
<p class="ggui-muted">The 6-digit code printed on your terminal when you ran <span class="ggui-code">ggui serve</span>. One-shot — restart the server for a fresh code.</p>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Self-hosted MCP server for the ggui protocol. An HTTP/MCP binding layer over @ggui-ai/mcp-server-handlers and the reference adapters from @ggui-ai/mcp-server-core — run it via `ggui serve`.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ggui",
|
|
@@ -32,17 +32,17 @@
|
|
|
32
32
|
"resend": "^6.12.2",
|
|
33
33
|
"ws": "^8.20.1",
|
|
34
34
|
"zod": "^4.3.6",
|
|
35
|
-
"@ggui-ai/console": "0.
|
|
36
|
-
"@ggui-ai/
|
|
37
|
-
"@ggui-ai/
|
|
38
|
-
"@ggui-ai/
|
|
39
|
-
"@ggui-ai/mcp-server-handlers": "0.
|
|
40
|
-
"@ggui-ai/negotiator": "0.
|
|
41
|
-
"@ggui-ai/
|
|
42
|
-
"@ggui-ai/
|
|
43
|
-
"@ggui-ai/
|
|
44
|
-
"@ggui-ai/
|
|
45
|
-
"@ggui-ai/ui-registry": "0.
|
|
35
|
+
"@ggui-ai/console": "0.20.0",
|
|
36
|
+
"@ggui-ai/design": "0.20.0",
|
|
37
|
+
"@ggui-ai/mcp-server-core": "0.20.0",
|
|
38
|
+
"@ggui-ai/iframe-runtime": "0.20.0",
|
|
39
|
+
"@ggui-ai/mcp-server-handlers": "0.20.0",
|
|
40
|
+
"@ggui-ai/negotiator": "0.20.0",
|
|
41
|
+
"@ggui-ai/project-config": "0.20.0",
|
|
42
|
+
"@ggui-ai/preview-a2ui": "0.20.0",
|
|
43
|
+
"@ggui-ai/ui-gen": "0.20.0",
|
|
44
|
+
"@ggui-ai/protocol": "0.20.0",
|
|
45
|
+
"@ggui-ai/ui-registry": "0.20.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/express": "^5.0.0",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@types/ws": "^8.5.10",
|
|
52
52
|
"typescript": "^5.0.0",
|
|
53
53
|
"vitest": "^3.2.6",
|
|
54
|
-
"@ggui-ai/protocol-conformance": "0.
|
|
54
|
+
"@ggui-ai/protocol-conformance": "0.20.0"
|
|
55
55
|
},
|
|
56
56
|
"repository": {
|
|
57
57
|
"type": "git",
|