mcp-proxy 5.5.6 → 5.6.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.5.6"
6
+ "version": "5.6.0"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy",
3
- "version": "5.5.6",
3
+ "version": "5.6.0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -0,0 +1,117 @@
1
+ import { IncomingMessage } from "http";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { AuthenticationMiddleware } from "./authentication.js";
5
+
6
+ describe("AuthenticationMiddleware", () => {
7
+ const createMockRequest = (headers: Record<string, string> = {}): IncomingMessage => {
8
+ // Simulate Node.js http module behavior which converts all header names to lowercase
9
+ const lowercaseHeaders: Record<string, string> = {};
10
+ for (const [key, value] of Object.entries(headers)) {
11
+ lowercaseHeaders[key.toLowerCase()] = value;
12
+ }
13
+ return {
14
+ headers: lowercaseHeaders,
15
+ } as IncomingMessage;
16
+ };
17
+
18
+ describe("when no auth is configured", () => {
19
+ it("should allow all requests", () => {
20
+ const middleware = new AuthenticationMiddleware({});
21
+ const req = createMockRequest();
22
+
23
+ expect(middleware.validateRequest(req)).toBe(true);
24
+ });
25
+
26
+ it("should allow requests even with headers", () => {
27
+ const middleware = new AuthenticationMiddleware({});
28
+ const req = createMockRequest({ "x-api-key": "some-key" });
29
+
30
+ expect(middleware.validateRequest(req)).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe("X-API-Key validation", () => {
35
+ const apiKey = "test-api-key-123";
36
+
37
+ it("should accept valid API key", () => {
38
+ const middleware = new AuthenticationMiddleware({ apiKey });
39
+ const req = createMockRequest({ "x-api-key": apiKey });
40
+
41
+ expect(middleware.validateRequest(req)).toBe(true);
42
+ });
43
+
44
+ it("should reject missing API key", () => {
45
+ const middleware = new AuthenticationMiddleware({ apiKey });
46
+ const req = createMockRequest();
47
+
48
+ expect(middleware.validateRequest(req)).toBe(false);
49
+ });
50
+
51
+ it("should reject incorrect API key", () => {
52
+ const middleware = new AuthenticationMiddleware({ apiKey });
53
+ const req = createMockRequest({ "x-api-key": "wrong-key" });
54
+
55
+ expect(middleware.validateRequest(req)).toBe(false);
56
+ });
57
+
58
+ it("should reject empty API key", () => {
59
+ const middleware = new AuthenticationMiddleware({ apiKey });
60
+ const req = createMockRequest({ "x-api-key": "" });
61
+
62
+ expect(middleware.validateRequest(req)).toBe(false);
63
+ });
64
+
65
+ it("should be case-insensitive for header names", () => {
66
+ const middleware = new AuthenticationMiddleware({ apiKey });
67
+ const req = createMockRequest({ "X-API-KEY": apiKey });
68
+
69
+ expect(middleware.validateRequest(req)).toBe(true);
70
+ });
71
+
72
+ it("should work with mixed case header names", () => {
73
+ const middleware = new AuthenticationMiddleware({ apiKey });
74
+ const req = createMockRequest({ "X-Api-Key": apiKey });
75
+
76
+ expect(middleware.validateRequest(req)).toBe(true);
77
+ });
78
+
79
+ it("should handle array headers (if multiple same headers)", () => {
80
+ const middleware = new AuthenticationMiddleware({ apiKey });
81
+ const req = {
82
+ headers: {
83
+ "x-api-key": [apiKey, "another-key"],
84
+ },
85
+ } as unknown as IncomingMessage;
86
+
87
+ // Should fail because header is an array, not a string
88
+ expect(middleware.validateRequest(req)).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe("getUnauthorizedResponse", () => {
93
+ it("should return proper unauthorized response", () => {
94
+ const middleware = new AuthenticationMiddleware({ apiKey: "test" });
95
+ const response = middleware.getUnauthorizedResponse();
96
+
97
+ expect(response.headers["Content-Type"]).toBe("application/json");
98
+
99
+ const body = JSON.parse(response.body);
100
+ expect(body.error.code).toBe(401);
101
+ expect(body.error.message).toBe("Unauthorized: Invalid or missing API key");
102
+ expect(body.jsonrpc).toBe("2.0");
103
+ expect(body.id).toBe(null);
104
+ });
105
+
106
+ it("should have consistent format regardless of configuration", () => {
107
+ const middleware1 = new AuthenticationMiddleware({});
108
+ const middleware2 = new AuthenticationMiddleware({ apiKey: "test" });
109
+
110
+ const response1 = middleware1.getUnauthorizedResponse();
111
+ const response2 = middleware2.getUnauthorizedResponse();
112
+
113
+ expect(response1.headers).toEqual(response2.headers);
114
+ expect(response1.body).toEqual(response2.body);
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,43 @@
1
+ import type { IncomingMessage } from "http";
2
+
3
+ export interface AuthConfig {
4
+ apiKey?: string;
5
+ }
6
+
7
+ export class AuthenticationMiddleware {
8
+ constructor(private config: AuthConfig = {}) {}
9
+
10
+ getUnauthorizedResponse() {
11
+ return {
12
+ body: JSON.stringify({
13
+ error: {
14
+ code: 401,
15
+ message: "Unauthorized: Invalid or missing API key",
16
+ },
17
+ id: null,
18
+ jsonrpc: "2.0",
19
+ }),
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ },
23
+ };
24
+ }
25
+
26
+ validateRequest(req: IncomingMessage): boolean {
27
+ // No auth required if no API key configured (backward compatibility)
28
+ if (!this.config.apiKey) {
29
+ return true;
30
+ }
31
+
32
+ // Check X-API-Key header (case-insensitive)
33
+ // Node.js http module automatically converts all header names to lowercase
34
+ const apiKey = req.headers["x-api-key"];
35
+
36
+ if (!apiKey || typeof apiKey !== "string") {
37
+ return false;
38
+ }
39
+
40
+ return apiKey === this.config.apiKey;
41
+ }
42
+ }
43
+
@@ -38,6 +38,10 @@ const argv = await yargs(hideBin(process.argv))
38
38
  "populate--": true,
39
39
  })
40
40
  .options({
41
+ apiKey: {
42
+ describe: "API key for authenticating requests (uses X-API-Key header)",
43
+ type: "string",
44
+ },
41
45
  debug: {
42
46
  default: false,
43
47
  describe: "Enable debug logging",
@@ -160,6 +164,7 @@ const proxy = async () => {
160
164
  };
161
165
 
162
166
  const server = await startHTTPServer({
167
+ apiKey: argv.apiKey,
163
168
  createServer,
164
169
  eventStore: new InMemoryEventStore(),
165
170
  host: argv.host,
@@ -327,3 +327,377 @@ it("supports stateless HTTP streamable transport", async () => {
327
327
  // Note: in stateless mode, onClose behavior may differ since there's no persistent session
328
328
  await delay(100);
329
329
  });
330
+
331
+ it("allows requests when no auth is configured", async () => {
332
+ const stdioTransport = new StdioClientTransport({
333
+ args: ["src/fixtures/simple-stdio-server.ts"],
334
+ command: "tsx",
335
+ });
336
+
337
+ const stdioClient = new Client(
338
+ {
339
+ name: "mcp-proxy",
340
+ version: "1.0.0",
341
+ },
342
+ {
343
+ capabilities: {},
344
+ },
345
+ );
346
+
347
+ await stdioClient.connect(stdioTransport);
348
+
349
+ const serverVersion = stdioClient.getServerVersion() as {
350
+ name: string;
351
+ version: string;
352
+ };
353
+
354
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
355
+ capabilities: Record<string, unknown>;
356
+ };
357
+
358
+ const port = await getRandomPort();
359
+
360
+ const httpServer = await startHTTPServer({
361
+ // No apiKey configured
362
+ createServer: async () => {
363
+ const mcpServer = new Server(serverVersion, {
364
+ capabilities: serverCapabilities,
365
+ });
366
+
367
+ await proxyServer({
368
+ client: stdioClient,
369
+ server: mcpServer,
370
+ serverCapabilities,
371
+ });
372
+
373
+ return mcpServer;
374
+ },
375
+ port,
376
+ });
377
+
378
+ const streamClient = new Client(
379
+ {
380
+ name: "stream-client",
381
+ version: "1.0.0",
382
+ },
383
+ {
384
+ capabilities: {},
385
+ },
386
+ );
387
+
388
+ // Connect without any authentication header
389
+ const transport = new StreamableHTTPClientTransport(
390
+ new URL(`http://localhost:${port}/mcp`),
391
+ );
392
+
393
+ await streamClient.connect(transport);
394
+
395
+ // Should be able to make requests without auth
396
+ const result = await streamClient.listResources();
397
+ expect(result).toEqual({
398
+ resources: [
399
+ {
400
+ name: "Example Resource",
401
+ uri: "file:///example.txt",
402
+ },
403
+ ],
404
+ });
405
+
406
+ await streamClient.close();
407
+ await httpServer.close();
408
+ await stdioClient.close();
409
+ });
410
+
411
+ it("rejects requests without API key when auth is enabled", async () => {
412
+ const stdioTransport = new StdioClientTransport({
413
+ args: ["src/fixtures/simple-stdio-server.ts"],
414
+ command: "tsx",
415
+ });
416
+
417
+ const stdioClient = new Client(
418
+ {
419
+ name: "mcp-proxy",
420
+ version: "1.0.0",
421
+ },
422
+ {
423
+ capabilities: {},
424
+ },
425
+ );
426
+
427
+ await stdioClient.connect(stdioTransport);
428
+
429
+ const serverVersion = stdioClient.getServerVersion() as {
430
+ name: string;
431
+ version: string;
432
+ };
433
+
434
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
435
+ capabilities: Record<string, unknown>;
436
+ };
437
+
438
+ const port = await getRandomPort();
439
+
440
+ const httpServer = await startHTTPServer({
441
+ apiKey: "test-api-key-123", // API key configured
442
+ createServer: async () => {
443
+ const mcpServer = new Server(serverVersion, {
444
+ capabilities: serverCapabilities,
445
+ });
446
+
447
+ await proxyServer({
448
+ client: stdioClient,
449
+ server: mcpServer,
450
+ serverCapabilities,
451
+ });
452
+
453
+ return mcpServer;
454
+ },
455
+ port,
456
+ });
457
+
458
+ // Try to connect without authentication header
459
+ const transport = new StreamableHTTPClientTransport(
460
+ new URL(`http://localhost:${port}/mcp`),
461
+ );
462
+
463
+ const streamClient = new Client(
464
+ {
465
+ name: "stream-client",
466
+ version: "1.0.0",
467
+ },
468
+ {
469
+ capabilities: {},
470
+ },
471
+ );
472
+
473
+ // Connection should fail due to missing auth
474
+ await expect(streamClient.connect(transport)).rejects.toThrow();
475
+
476
+ await httpServer.close();
477
+ await stdioClient.close();
478
+ });
479
+
480
+ it("accepts requests with valid API key", async () => {
481
+ const stdioTransport = new StdioClientTransport({
482
+ args: ["src/fixtures/simple-stdio-server.ts"],
483
+ command: "tsx",
484
+ });
485
+
486
+ const stdioClient = new Client(
487
+ {
488
+ name: "mcp-proxy",
489
+ version: "1.0.0",
490
+ },
491
+ {
492
+ capabilities: {},
493
+ },
494
+ );
495
+
496
+ await stdioClient.connect(stdioTransport);
497
+
498
+ const serverVersion = stdioClient.getServerVersion() as {
499
+ name: string;
500
+ version: string;
501
+ };
502
+
503
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
504
+ capabilities: Record<string, unknown>;
505
+ };
506
+
507
+ const port = await getRandomPort();
508
+ const apiKey = "test-api-key-123";
509
+
510
+ const httpServer = await startHTTPServer({
511
+ apiKey,
512
+ createServer: async () => {
513
+ const mcpServer = new Server(serverVersion, {
514
+ capabilities: serverCapabilities,
515
+ });
516
+
517
+ await proxyServer({
518
+ client: stdioClient,
519
+ server: mcpServer,
520
+ serverCapabilities,
521
+ });
522
+
523
+ return mcpServer;
524
+ },
525
+ port,
526
+ });
527
+
528
+ // Connect with proper authentication header
529
+ const transport = new StreamableHTTPClientTransport(
530
+ new URL(`http://localhost:${port}/mcp`),
531
+ {
532
+ requestInit: {
533
+ headers: {
534
+ "X-API-Key": apiKey,
535
+ },
536
+ },
537
+ },
538
+ );
539
+
540
+ const streamClient = new Client(
541
+ {
542
+ name: "stream-client",
543
+ version: "1.0.0",
544
+ },
545
+ {
546
+ capabilities: {},
547
+ },
548
+ );
549
+
550
+ await streamClient.connect(transport);
551
+
552
+ // Should be able to make requests with valid auth
553
+ const result = await streamClient.listResources();
554
+ expect(result).toEqual({
555
+ resources: [
556
+ {
557
+ name: "Example Resource",
558
+ uri: "file:///example.txt",
559
+ },
560
+ ],
561
+ });
562
+
563
+ await streamClient.close();
564
+ await httpServer.close();
565
+ await stdioClient.close();
566
+ });
567
+
568
+ it("works with SSE transport and authentication", async () => {
569
+ const stdioTransport = new StdioClientTransport({
570
+ args: ["src/fixtures/simple-stdio-server.ts"],
571
+ command: "tsx",
572
+ });
573
+
574
+ const stdioClient = new Client(
575
+ {
576
+ name: "mcp-proxy",
577
+ version: "1.0.0",
578
+ },
579
+ {
580
+ capabilities: {},
581
+ },
582
+ );
583
+
584
+ await stdioClient.connect(stdioTransport);
585
+
586
+ const serverVersion = stdioClient.getServerVersion() as {
587
+ name: string;
588
+ version: string;
589
+ };
590
+
591
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
592
+ capabilities: Record<string, unknown>;
593
+ };
594
+
595
+ const port = await getRandomPort();
596
+ const apiKey = "test-api-key-456";
597
+
598
+ const httpServer = await startHTTPServer({
599
+ apiKey,
600
+ createServer: async () => {
601
+ const mcpServer = new Server(serverVersion, {
602
+ capabilities: serverCapabilities,
603
+ });
604
+
605
+ await proxyServer({
606
+ client: stdioClient,
607
+ server: mcpServer,
608
+ serverCapabilities,
609
+ });
610
+
611
+ return mcpServer;
612
+ },
613
+ port,
614
+ });
615
+
616
+ // Connect with proper authentication header for SSE
617
+ const transport = new SSEClientTransport(
618
+ new URL(`http://localhost:${port}/sse`),
619
+ {
620
+ requestInit: {
621
+ headers: {
622
+ "X-API-Key": apiKey,
623
+ },
624
+ },
625
+ },
626
+ );
627
+
628
+ const sseClient = new Client(
629
+ {
630
+ name: "sse-client",
631
+ version: "1.0.0",
632
+ },
633
+ {
634
+ capabilities: {},
635
+ },
636
+ );
637
+
638
+ await sseClient.connect(transport);
639
+
640
+ // Should be able to make requests with valid auth
641
+ const result = await sseClient.listResources();
642
+ expect(result).toEqual({
643
+ resources: [
644
+ {
645
+ name: "Example Resource",
646
+ uri: "file:///example.txt",
647
+ },
648
+ ],
649
+ });
650
+
651
+ await sseClient.close();
652
+ await httpServer.close();
653
+ await stdioClient.close();
654
+ });
655
+
656
+ it("does not require auth for /ping endpoint", async () => {
657
+ const port = await getRandomPort();
658
+ const apiKey = "test-api-key-789";
659
+
660
+ const httpServer = await startHTTPServer({
661
+ apiKey,
662
+ createServer: async () => {
663
+ const mcpServer = new Server(
664
+ { name: "test", version: "1.0.0" },
665
+ { capabilities: {} },
666
+ );
667
+ return mcpServer;
668
+ },
669
+ port,
670
+ });
671
+
672
+ // Test /ping without auth header
673
+ const response = await fetch(`http://localhost:${port}/ping`);
674
+ expect(response.status).toBe(200);
675
+ expect(await response.text()).toBe("pong");
676
+
677
+ await httpServer.close();
678
+ });
679
+
680
+ it("does not require auth for OPTIONS requests", async () => {
681
+ const port = await getRandomPort();
682
+ const apiKey = "test-api-key-999";
683
+
684
+ const httpServer = await startHTTPServer({
685
+ apiKey,
686
+ createServer: async () => {
687
+ const mcpServer = new Server(
688
+ { name: "test", version: "1.0.0" },
689
+ { capabilities: {} },
690
+ );
691
+ return mcpServer;
692
+ },
693
+ port,
694
+ });
695
+
696
+ // Test OPTIONS without auth header
697
+ const response = await fetch(`http://localhost:${port}/mcp`, {
698
+ method: "OPTIONS",
699
+ });
700
+ expect(response.status).toBe(204);
701
+
702
+ await httpServer.close();
703
+ });
@@ -8,6 +8,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
8
8
  import http from "http";
9
9
  import { randomUUID } from "node:crypto";
10
10
 
11
+ import { AuthenticationMiddleware } from "./authentication.js";
11
12
  import { InMemoryEventStore } from "./InMemoryEventStore.js";
12
13
 
13
14
  export type SSEServer = {
@@ -455,6 +456,7 @@ const handleSSERequest = async <T extends ServerLike>({
455
456
  };
456
457
 
457
458
  export const startHTTPServer = async <T extends ServerLike>({
459
+ apiKey,
458
460
  createServer,
459
461
  enableJsonResponse,
460
462
  eventStore,
@@ -467,6 +469,7 @@ export const startHTTPServer = async <T extends ServerLike>({
467
469
  stateless,
468
470
  streamEndpoint = "/mcp",
469
471
  }: {
472
+ apiKey?: string;
470
473
  createServer: (request: http.IncomingMessage) => Promise<T>;
471
474
  enableJsonResponse?: boolean;
472
475
  eventStore?: EventStore;
@@ -492,6 +495,8 @@ export const startHTTPServer = async <T extends ServerLike>({
492
495
  }
493
496
  > = {};
494
497
 
498
+ const authMiddleware = new AuthenticationMiddleware({ apiKey });
499
+
495
500
  /**
496
501
  * @author https://dev.classmethod.jp/articles/mcp-sse/
497
502
  */
@@ -521,6 +526,14 @@ export const startHTTPServer = async <T extends ServerLike>({
521
526
  return;
522
527
  }
523
528
 
529
+ // Check authentication for all other endpoints
530
+ if (!authMiddleware.validateRequest(req)) {
531
+ const authResponse = authMiddleware.getUnauthorizedResponse();
532
+ res.writeHead(401, authResponse.headers);
533
+ res.end(authResponse.body);
534
+ return;
535
+ }
536
+
524
537
  if (
525
538
  sseEndpoint &&
526
539
  (await handleSSERequest({