mcp-proxy 5.11.2 → 5.12.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/jsr.json CHANGED
@@ -3,5 +3,5 @@
3
3
  "include": ["src/index.ts", "src/bin/mcp-proxy.ts"],
4
4
  "license": "MIT",
5
5
  "name": "@punkpeye/mcp-proxy",
6
- "version": "5.11.2"
6
+ "version": "5.12.0"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy",
3
- "version": "5.11.2",
3
+ "version": "5.12.0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -327,4 +327,199 @@ describe("AuthenticationMiddleware", () => {
327
327
  expect(header.indexOf('error=')).toBeLessThan(header.indexOf('error_description='));
328
328
  });
329
329
  });
330
+
331
+ describe("getScopeChallengeResponse", () => {
332
+ it("should return 403 status code", () => {
333
+ const middleware = new AuthenticationMiddleware({
334
+ oauth: {
335
+ protectedResource: {
336
+ resource: "https://example.com",
337
+ },
338
+ },
339
+ });
340
+ const response = middleware.getScopeChallengeResponse(["read", "write"]);
341
+
342
+ expect(response.statusCode).toBe(403);
343
+ });
344
+
345
+ it("should include required scopes in WWW-Authenticate header", () => {
346
+ const middleware = new AuthenticationMiddleware({
347
+ oauth: {
348
+ protectedResource: {
349
+ resource: "https://example.com",
350
+ },
351
+ },
352
+ });
353
+ const response = middleware.getScopeChallengeResponse(["read", "write"]);
354
+
355
+ expect(response.headers["WWW-Authenticate"]).toContain('error="insufficient_scope"');
356
+ expect(response.headers["WWW-Authenticate"]).toContain('scope="read write"');
357
+ expect(response.headers["WWW-Authenticate"]).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"');
358
+ });
359
+
360
+ it("should include error_description in WWW-Authenticate header", () => {
361
+ const middleware = new AuthenticationMiddleware({
362
+ oauth: {
363
+ protectedResource: {
364
+ resource: "https://example.com",
365
+ },
366
+ },
367
+ });
368
+ const response = middleware.getScopeChallengeResponse(
369
+ ["admin"],
370
+ "Admin access required",
371
+ );
372
+
373
+ expect(response.headers["WWW-Authenticate"]).toContain('error_description="Admin access required"');
374
+ });
375
+
376
+ it("should escape quotes in error_description", () => {
377
+ const middleware = new AuthenticationMiddleware({
378
+ oauth: {
379
+ protectedResource: {
380
+ resource: "https://example.com",
381
+ },
382
+ },
383
+ });
384
+ const response = middleware.getScopeChallengeResponse(
385
+ ["admin"],
386
+ 'Requires "admin" scope',
387
+ );
388
+
389
+ expect(response.headers["WWW-Authenticate"]).toContain('error_description="Requires \\"admin\\" scope"');
390
+ });
391
+
392
+ it("should include request ID in response body", () => {
393
+ const middleware = new AuthenticationMiddleware({
394
+ oauth: {
395
+ protectedResource: {
396
+ resource: "https://example.com",
397
+ },
398
+ },
399
+ });
400
+ const response = middleware.getScopeChallengeResponse(["read"], undefined, 123);
401
+
402
+ const body = JSON.parse(response.body);
403
+ expect(body.id).toBe(123);
404
+ });
405
+
406
+ it("should include required scopes in response body data", () => {
407
+ const middleware = new AuthenticationMiddleware({
408
+ oauth: {
409
+ protectedResource: {
410
+ resource: "https://example.com",
411
+ },
412
+ },
413
+ });
414
+ const response = middleware.getScopeChallengeResponse(["read", "write"]);
415
+
416
+ const body = JSON.parse(response.body);
417
+ expect(body.error.code).toBe(-32001);
418
+ expect(body.error.message).toBe("Insufficient scope");
419
+ expect(body.error.data.error).toBe("insufficient_scope");
420
+ expect(body.error.data.required_scopes).toEqual(["read", "write"]);
421
+ });
422
+
423
+ it("should use custom error_description in response body message", () => {
424
+ const middleware = new AuthenticationMiddleware({
425
+ oauth: {
426
+ protectedResource: {
427
+ resource: "https://example.com",
428
+ },
429
+ },
430
+ });
431
+ const response = middleware.getScopeChallengeResponse(
432
+ ["admin"],
433
+ "Admin privileges required",
434
+ );
435
+
436
+ const body = JSON.parse(response.body);
437
+ expect(body.error.message).toBe("Admin privileges required");
438
+ });
439
+
440
+ it("should handle single scope", () => {
441
+ const middleware = new AuthenticationMiddleware({
442
+ oauth: {
443
+ protectedResource: {
444
+ resource: "https://example.com",
445
+ },
446
+ },
447
+ });
448
+ const response = middleware.getScopeChallengeResponse(["admin"]);
449
+
450
+ expect(response.headers["WWW-Authenticate"]).toContain('scope="admin"');
451
+ const body = JSON.parse(response.body);
452
+ expect(body.error.data.required_scopes).toEqual(["admin"]);
453
+ });
454
+
455
+ it("should handle multiple scopes", () => {
456
+ const middleware = new AuthenticationMiddleware({
457
+ oauth: {
458
+ protectedResource: {
459
+ resource: "https://example.com",
460
+ },
461
+ },
462
+ });
463
+ const response = middleware.getScopeChallengeResponse(["read", "write", "admin"]);
464
+
465
+ expect(response.headers["WWW-Authenticate"]).toContain('scope="read write admin"');
466
+ const body = JSON.parse(response.body);
467
+ expect(body.error.data.required_scopes).toEqual(["read", "write", "admin"]);
468
+ });
469
+
470
+ it("should not include WWW-Authenticate header without OAuth config", () => {
471
+ const middleware = new AuthenticationMiddleware({});
472
+ const response = middleware.getScopeChallengeResponse(["admin"]);
473
+
474
+ expect(response.headers["WWW-Authenticate"]).toBeUndefined();
475
+ expect(response.headers["Content-Type"]).toBe("application/json");
476
+ });
477
+
478
+ it("should return proper JSON-RPC 2.0 format", () => {
479
+ const middleware = new AuthenticationMiddleware({
480
+ oauth: {
481
+ protectedResource: {
482
+ resource: "https://example.com",
483
+ },
484
+ },
485
+ });
486
+ const response = middleware.getScopeChallengeResponse(["read"], "Description", "req-123");
487
+
488
+ const body = JSON.parse(response.body);
489
+ expect(body.jsonrpc).toBe("2.0");
490
+ expect(body.id).toBe("req-123");
491
+ expect(body.error).toBeDefined();
492
+ expect(body.error.code).toBe(-32001);
493
+ expect(body.error.message).toBe("Description");
494
+ expect(body.error.data).toBeDefined();
495
+ });
496
+
497
+ it("should include Content-Type header", () => {
498
+ const middleware = new AuthenticationMiddleware({
499
+ oauth: {
500
+ protectedResource: {
501
+ resource: "https://example.com",
502
+ },
503
+ },
504
+ });
505
+ const response = middleware.getScopeChallengeResponse(["read"]);
506
+
507
+ expect(response.headers["Content-Type"]).toBe("application/json");
508
+ });
509
+
510
+ it("should handle empty scopes array", () => {
511
+ const middleware = new AuthenticationMiddleware({
512
+ oauth: {
513
+ protectedResource: {
514
+ resource: "https://example.com",
515
+ },
516
+ },
517
+ });
518
+ const response = middleware.getScopeChallengeResponse([]);
519
+
520
+ expect(response.headers["WWW-Authenticate"]).toContain('scope=""');
521
+ const body = JSON.parse(response.body);
522
+ expect(body.error.data.required_scopes).toEqual([]);
523
+ });
524
+ });
330
525
  });
@@ -17,6 +17,51 @@ export interface AuthConfig {
17
17
  export class AuthenticationMiddleware {
18
18
  constructor(private config: AuthConfig = {}) {}
19
19
 
20
+ getScopeChallengeResponse(
21
+ requiredScopes: string[],
22
+ errorDescription?: string,
23
+ requestId?: unknown,
24
+ ): { body: string; headers: Record<string, string>; statusCode: number } {
25
+ const headers: Record<string, string> = {
26
+ "Content-Type": "application/json",
27
+ };
28
+
29
+ // Build WWW-Authenticate header with all required parameters
30
+ if (this.config.oauth?.protectedResource?.resource) {
31
+ const parts = [
32
+ "Bearer",
33
+ 'error="insufficient_scope"',
34
+ `scope="${requiredScopes.join(" ")}"`,
35
+ `resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`,
36
+ ];
37
+
38
+ if (errorDescription) {
39
+ // Escape quotes in description
40
+ const escaped = errorDescription.replace(/"/g, '\\"');
41
+ parts.push(`error_description="${escaped}"`);
42
+ }
43
+
44
+ headers["WWW-Authenticate"] = parts.join(", ");
45
+ }
46
+
47
+ return {
48
+ body: JSON.stringify({
49
+ error: {
50
+ code: -32001, // Custom error code for insufficient scope
51
+ data: {
52
+ error: "insufficient_scope",
53
+ required_scopes: requiredScopes,
54
+ },
55
+ message: errorDescription || "Insufficient scope",
56
+ },
57
+ id: requestId ?? null,
58
+ jsonrpc: "2.0",
59
+ }),
60
+ headers,
61
+ statusCode: 403,
62
+ };
63
+ }
64
+
20
65
  getUnauthorizedResponse(options?: {
21
66
  error?: string;
22
67
  error_description?: string;
@@ -118,6 +118,28 @@ const getWWWAuthenticateHeader = (
118
118
  return `Bearer ${params.join(", ")}`;
119
119
  };
120
120
 
121
+ // Helper function to detect scope challenge errors
122
+ const isScopeChallengeError = (error: unknown): error is {
123
+ data: {
124
+ error: string;
125
+ errorDescription?: string;
126
+ requiredScopes: string[];
127
+ };
128
+ name: string;
129
+ } => {
130
+ return (
131
+ typeof error === "object" &&
132
+ error !== null &&
133
+ "name" in error &&
134
+ error.name === "InsufficientScopeError" &&
135
+ "data" in error &&
136
+ typeof error.data === "object" &&
137
+ error.data !== null &&
138
+ "error" in error.data &&
139
+ error.data.error === "insufficient_scope"
140
+ );
141
+ };
142
+
121
143
  // Helper function to handle Response errors and send appropriate HTTP response
122
144
  const handleResponseError = async (
123
145
  error: unknown,
@@ -267,6 +289,7 @@ const applyCorsHeaders = (
267
289
  const handleStreamRequest = async <T extends ServerLike>({
268
290
  activeTransports,
269
291
  authenticate,
292
+ authMiddleware,
270
293
  createServer,
271
294
  enableJsonResponse,
272
295
  endpoint,
@@ -283,6 +306,7 @@ const handleStreamRequest = async <T extends ServerLike>({
283
306
  { server: T; transport: StreamableHTTPServerTransport }
284
307
  >;
285
308
  authenticate?: (request: http.IncomingMessage) => Promise<unknown>;
309
+ authMiddleware: AuthenticationMiddleware;
286
310
  createServer: (request: http.IncomingMessage) => Promise<T>;
287
311
  enableJsonResponse?: boolean;
288
312
  endpoint: string;
@@ -298,6 +322,7 @@ const handleStreamRequest = async <T extends ServerLike>({
298
322
  req.method === "POST" &&
299
323
  new URL(req.url!, "http://localhost").pathname === endpoint
300
324
  ) {
325
+ let body: unknown;
301
326
  try {
302
327
  const sessionId = Array.isArray(req.headers["mcp-session-id"])
303
328
  ? req.headers["mcp-session-id"][0]
@@ -307,7 +332,7 @@ const handleStreamRequest = async <T extends ServerLike>({
307
332
 
308
333
  let server: T;
309
334
 
310
- const body = await getBody(req);
335
+ body = await getBody(req);
311
336
 
312
337
  // Per-request authentication in stateless mode
313
338
  if (stateless && authenticate) {
@@ -566,6 +591,19 @@ const handleStreamRequest = async <T extends ServerLike>({
566
591
 
567
592
  return true;
568
593
  } catch (error) {
594
+ // Check for scope challenge errors
595
+ if (isScopeChallengeError(error)) {
596
+ const response = authMiddleware.getScopeChallengeResponse(
597
+ error.data.requiredScopes,
598
+ error.data.errorDescription,
599
+ (body as { id?: unknown })?.id,
600
+ );
601
+
602
+ res.writeHead(response.statusCode, response.headers);
603
+ res.end(response.body);
604
+ return true;
605
+ }
606
+
569
607
  console.error("[mcp-proxy] error handling request", error);
570
608
 
571
609
  res.setHeader("Content-Type", "application/json");
@@ -858,6 +896,7 @@ export const startHTTPServer = async <T extends ServerLike>({
858
896
  (await handleStreamRequest({
859
897
  activeTransports: activeStreamTransports,
860
898
  authenticate,
899
+ authMiddleware,
861
900
  createServer,
862
901
  enableJsonResponse,
863
902
  endpoint: streamEndpoint,