mcp-proxy 5.8.0 → 5.9.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.8.0"
6
+ "version": "5.9.0"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy",
3
- "version": "5.8.0",
3
+ "version": "5.9.0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -103,15 +103,90 @@ describe("AuthenticationMiddleware", () => {
103
103
  expect(body.id).toBe(null);
104
104
  });
105
105
 
106
- it("should have consistent format regardless of configuration", () => {
106
+ it("should have consistent body format regardless of configuration", () => {
107
107
  const middleware1 = new AuthenticationMiddleware({});
108
108
  const middleware2 = new AuthenticationMiddleware({ apiKey: "test" });
109
109
 
110
110
  const response1 = middleware1.getUnauthorizedResponse();
111
111
  const response2 = middleware2.getUnauthorizedResponse();
112
112
 
113
- expect(response1.headers).toEqual(response2.headers);
114
113
  expect(response1.body).toEqual(response2.body);
115
114
  });
115
+
116
+ it("should not include WWW-Authenticate header without OAuth config", () => {
117
+ const middleware = new AuthenticationMiddleware({ apiKey: "test" });
118
+ const response = middleware.getUnauthorizedResponse();
119
+
120
+ expect(response.headers["WWW-Authenticate"]).toBeUndefined();
121
+ });
122
+
123
+ it("should include WWW-Authenticate header with OAuth config", () => {
124
+ const middleware = new AuthenticationMiddleware({
125
+ apiKey: "test",
126
+ oauth: {
127
+ protectedResource: {
128
+ resource: "https://example.com",
129
+ },
130
+ },
131
+ });
132
+ const response = middleware.getUnauthorizedResponse();
133
+
134
+ expect(response.headers["WWW-Authenticate"]).toBe(
135
+ 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
136
+ );
137
+ });
138
+
139
+ it("should handle OAuth config with trailing slash in resource URL", () => {
140
+ const middleware = new AuthenticationMiddleware({
141
+ apiKey: "test",
142
+ oauth: {
143
+ protectedResource: {
144
+ resource: "https://example.com/",
145
+ },
146
+ },
147
+ });
148
+ const response = middleware.getUnauthorizedResponse();
149
+
150
+ expect(response.headers["WWW-Authenticate"]).toBe(
151
+ 'Bearer resource_metadata="https://example.com//.well-known/oauth-protected-resource"',
152
+ );
153
+ });
154
+
155
+ it("should not include WWW-Authenticate header when OAuth config is empty", () => {
156
+ const middleware = new AuthenticationMiddleware({
157
+ apiKey: "test",
158
+ oauth: {},
159
+ });
160
+ const response = middleware.getUnauthorizedResponse();
161
+
162
+ expect(response.headers["WWW-Authenticate"]).toBeUndefined();
163
+ });
164
+
165
+ it("should not include WWW-Authenticate header when protectedResource is empty", () => {
166
+ const middleware = new AuthenticationMiddleware({
167
+ apiKey: "test",
168
+ oauth: {
169
+ protectedResource: {},
170
+ },
171
+ });
172
+ const response = middleware.getUnauthorizedResponse();
173
+
174
+ expect(response.headers["WWW-Authenticate"]).toBeUndefined();
175
+ });
176
+
177
+ it("should include WWW-Authenticate header with OAuth config but no apiKey", () => {
178
+ const middleware = new AuthenticationMiddleware({
179
+ oauth: {
180
+ protectedResource: {
181
+ resource: "https://example.com",
182
+ },
183
+ },
184
+ });
185
+ const response = middleware.getUnauthorizedResponse();
186
+
187
+ expect(response.headers["WWW-Authenticate"]).toBe(
188
+ 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
189
+ );
190
+ });
116
191
  });
117
192
  });
@@ -2,12 +2,26 @@ import type { IncomingMessage } from "http";
2
2
 
3
3
  export interface AuthConfig {
4
4
  apiKey?: string;
5
+ oauth?: {
6
+ protectedResource?: {
7
+ resource?: string;
8
+ };
9
+ };
5
10
  }
6
11
 
7
12
  export class AuthenticationMiddleware {
8
13
  constructor(private config: AuthConfig = {}) {}
9
14
 
10
- getUnauthorizedResponse() {
15
+ getUnauthorizedResponse(): { body: string; headers: Record<string, string> } {
16
+ const headers: Record<string, string> = {
17
+ "Content-Type": "application/json",
18
+ };
19
+
20
+ // Add WWW-Authenticate header if OAuth config is available
21
+ if (this.config.oauth?.protectedResource?.resource) {
22
+ headers["WWW-Authenticate"] = `Bearer resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`;
23
+ }
24
+
11
25
  return {
12
26
  body: JSON.stringify({
13
27
  error: {
@@ -17,9 +31,7 @@ export class AuthenticationMiddleware {
17
31
  id: null,
18
32
  jsonrpc: "2.0",
19
33
  }),
20
- headers: {
21
- "Content-Type": "application/json",
22
- },
34
+ headers,
23
35
  };
24
36
  }
25
37
 
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type { AuthConfig } from "./authentication.js";
2
+ export { AuthenticationMiddleware } from "./authentication.js";
1
3
  export { InMemoryEventStore } from "./InMemoryEventStore.js";
2
4
  export { proxyServer } from "./proxyServer.js";
3
5
  export { startHTTPServer } from "./startHTTPServer.js";
@@ -1181,3 +1181,497 @@ it("includes Authorization in CORS allowed headers", async () => {
1181
1181
 
1182
1182
  await httpServer.close();
1183
1183
  });
1184
+
1185
+ // Tests for FastMCP-style authentication with { authenticated: false } pattern
1186
+
1187
+ it("returns 401 when authenticate callback returns { authenticated: false } in stateless mode", async () => {
1188
+ const stdioTransport = new StdioClientTransport({
1189
+ args: ["src/fixtures/simple-stdio-server.ts"],
1190
+ command: "tsx",
1191
+ });
1192
+
1193
+ const stdioClient = new Client(
1194
+ {
1195
+ name: "mcp-proxy",
1196
+ version: "1.0.0",
1197
+ },
1198
+ {
1199
+ capabilities: {},
1200
+ },
1201
+ );
1202
+
1203
+ await stdioClient.connect(stdioTransport);
1204
+
1205
+ const serverVersion = stdioClient.getServerVersion() as {
1206
+ name: string;
1207
+ version: string;
1208
+ };
1209
+
1210
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
1211
+ capabilities: Record<string, unknown>;
1212
+ };
1213
+
1214
+ const port = await getRandomPort();
1215
+
1216
+ // Mock authenticate callback that returns { authenticated: false }
1217
+ const authenticate = vi.fn().mockResolvedValue({
1218
+ authenticated: false,
1219
+ error: "Invalid JWT token",
1220
+ });
1221
+
1222
+ const httpServer = await startHTTPServer({
1223
+ authenticate,
1224
+ createServer: async () => {
1225
+ const mcpServer = new Server(serverVersion, {
1226
+ capabilities: serverCapabilities,
1227
+ });
1228
+
1229
+ await proxyServer({
1230
+ client: stdioClient,
1231
+ server: mcpServer,
1232
+ serverCapabilities,
1233
+ });
1234
+
1235
+ return mcpServer;
1236
+ },
1237
+ port,
1238
+ stateless: true,
1239
+ });
1240
+
1241
+ // Create client with invalid Bearer token
1242
+ const streamTransport = new StreamableHTTPClientTransport(
1243
+ new URL(`http://localhost:${port}/mcp`),
1244
+ {
1245
+ requestInit: {
1246
+ headers: {
1247
+ Authorization: "Bearer invalid-jwt-token",
1248
+ },
1249
+ },
1250
+ },
1251
+ );
1252
+
1253
+ const streamClient = new Client(
1254
+ {
1255
+ name: "stream-client-auth-false",
1256
+ version: "1.0.0",
1257
+ },
1258
+ {
1259
+ capabilities: {},
1260
+ },
1261
+ );
1262
+
1263
+ // Connection should fail due to authentication returning false
1264
+ await expect(streamClient.connect(streamTransport)).rejects.toThrow();
1265
+
1266
+ // Verify authenticate callback was called
1267
+ expect(authenticate).toHaveBeenCalled();
1268
+
1269
+ await httpServer.close();
1270
+ await stdioClient.close();
1271
+ });
1272
+
1273
+ it("returns 401 with custom error message when { authenticated: false, error: '...' }", async () => {
1274
+ const stdioTransport = new StdioClientTransport({
1275
+ args: ["src/fixtures/simple-stdio-server.ts"],
1276
+ command: "tsx",
1277
+ });
1278
+
1279
+ const stdioClient = new Client(
1280
+ {
1281
+ name: "mcp-proxy",
1282
+ version: "1.0.0",
1283
+ },
1284
+ {
1285
+ capabilities: {},
1286
+ },
1287
+ );
1288
+
1289
+ await stdioClient.connect(stdioTransport);
1290
+
1291
+ const serverVersion = stdioClient.getServerVersion() as {
1292
+ name: string;
1293
+ version: string;
1294
+ };
1295
+
1296
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
1297
+ capabilities: Record<string, unknown>;
1298
+ };
1299
+
1300
+ const port = await getRandomPort();
1301
+
1302
+ const customErrorMessage = "Token expired at 2025-10-06T12:00:00Z";
1303
+
1304
+ // Mock authenticate callback with custom error message
1305
+ const authenticate = vi.fn().mockResolvedValue({
1306
+ authenticated: false,
1307
+ error: customErrorMessage,
1308
+ });
1309
+
1310
+ const httpServer = await startHTTPServer({
1311
+ authenticate,
1312
+ createServer: async () => {
1313
+ const mcpServer = new Server(serverVersion, {
1314
+ capabilities: serverCapabilities,
1315
+ });
1316
+
1317
+ await proxyServer({
1318
+ client: stdioClient,
1319
+ server: mcpServer,
1320
+ serverCapabilities,
1321
+ });
1322
+
1323
+ return mcpServer;
1324
+ },
1325
+ port,
1326
+ stateless: true,
1327
+ });
1328
+
1329
+ // Make request directly with fetch to check error message
1330
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1331
+ body: JSON.stringify({
1332
+ id: 1,
1333
+ jsonrpc: "2.0",
1334
+ method: "initialize",
1335
+ params: {
1336
+ capabilities: {},
1337
+ clientInfo: { name: "test", version: "1.0.0" },
1338
+ protocolVersion: "2024-11-05",
1339
+ },
1340
+ }),
1341
+ headers: {
1342
+ "Accept": "application/json, text/event-stream",
1343
+ "Authorization": "Bearer expired-token",
1344
+ "Content-Type": "application/json",
1345
+ },
1346
+ method: "POST",
1347
+ });
1348
+
1349
+ expect(response.status).toBe(401);
1350
+
1351
+ const errorResponse = (await response.json()) as {
1352
+ error: { code: number; message: string };
1353
+ id: null | number;
1354
+ jsonrpc: string;
1355
+ };
1356
+ expect(errorResponse.error.message).toBe(customErrorMessage);
1357
+
1358
+ await httpServer.close();
1359
+ await stdioClient.close();
1360
+ });
1361
+
1362
+ it("returns 401 when createServer throws authentication error", async () => {
1363
+ const stdioTransport = new StdioClientTransport({
1364
+ args: ["src/fixtures/simple-stdio-server.ts"],
1365
+ command: "tsx",
1366
+ });
1367
+
1368
+ const stdioClient = new Client(
1369
+ {
1370
+ name: "mcp-proxy",
1371
+ version: "1.0.0",
1372
+ },
1373
+ {
1374
+ capabilities: {},
1375
+ },
1376
+ );
1377
+
1378
+ await stdioClient.connect(stdioTransport);
1379
+
1380
+ const port = await getRandomPort();
1381
+
1382
+ // Mock authenticate that passes, but createServer throws auth error
1383
+ const authenticate = vi.fn().mockResolvedValue({
1384
+ authenticated: true,
1385
+ session: { userId: "test" },
1386
+ });
1387
+
1388
+ const httpServer = await startHTTPServer({
1389
+ authenticate,
1390
+ createServer: async () => {
1391
+ // Simulate FastMCP throwing error for authenticated: false
1392
+ throw new Error("Authentication failed: Invalid JWT payload");
1393
+ },
1394
+ port,
1395
+ stateless: true,
1396
+ });
1397
+
1398
+ // Make request
1399
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1400
+ body: JSON.stringify({
1401
+ id: 1,
1402
+ jsonrpc: "2.0",
1403
+ method: "initialize",
1404
+ params: {
1405
+ capabilities: {},
1406
+ clientInfo: { name: "test", version: "1.0.0" },
1407
+ protocolVersion: "2024-11-05",
1408
+ },
1409
+ }),
1410
+ headers: {
1411
+ "Accept": "application/json, text/event-stream",
1412
+ "Authorization": "Bearer test-token",
1413
+ "Content-Type": "application/json",
1414
+ },
1415
+ method: "POST",
1416
+ });
1417
+
1418
+ expect(response.status).toBe(401);
1419
+
1420
+ const errorResponse = (await response.json()) as {
1421
+ error: { code: number; message: string };
1422
+ id: null | number;
1423
+ jsonrpc: string;
1424
+ };
1425
+ expect(errorResponse.error.message).toContain("Authentication failed");
1426
+
1427
+ await httpServer.close();
1428
+ await stdioClient.close();
1429
+ });
1430
+
1431
+ it("returns 401 when createServer throws JWT-related error", async () => {
1432
+ const port = await getRandomPort();
1433
+
1434
+ const httpServer = await startHTTPServer({
1435
+ createServer: async () => {
1436
+ throw new Error("Invalid JWT signature");
1437
+ },
1438
+ port,
1439
+ stateless: true,
1440
+ });
1441
+
1442
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1443
+ body: JSON.stringify({
1444
+ id: 1,
1445
+ jsonrpc: "2.0",
1446
+ method: "initialize",
1447
+ params: {
1448
+ capabilities: {},
1449
+ clientInfo: { name: "test", version: "1.0.0" },
1450
+ protocolVersion: "2024-11-05",
1451
+ },
1452
+ }),
1453
+ headers: {
1454
+ "Accept": "application/json, text/event-stream",
1455
+ "Content-Type": "application/json",
1456
+ },
1457
+ method: "POST",
1458
+ });
1459
+
1460
+ expect(response.status).toBe(401);
1461
+
1462
+ const errorResponse = (await response.json()) as {
1463
+ error: { code: number; message: string };
1464
+ id: null | number;
1465
+ jsonrpc: string;
1466
+ };
1467
+ expect(errorResponse.error.message).toContain("Invalid JWT");
1468
+
1469
+ await httpServer.close();
1470
+ });
1471
+
1472
+ it("returns 401 when createServer throws Token-related error", async () => {
1473
+ const port = await getRandomPort();
1474
+
1475
+ const httpServer = await startHTTPServer({
1476
+ createServer: async () => {
1477
+ throw new Error("Token has been revoked");
1478
+ },
1479
+ port,
1480
+ stateless: true,
1481
+ });
1482
+
1483
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1484
+ body: JSON.stringify({
1485
+ id: 1,
1486
+ jsonrpc: "2.0",
1487
+ method: "initialize",
1488
+ params: {
1489
+ capabilities: {},
1490
+ clientInfo: { name: "test", version: "1.0.0" },
1491
+ protocolVersion: "2024-11-05",
1492
+ },
1493
+ }),
1494
+ headers: {
1495
+ "Accept": "application/json, text/event-stream",
1496
+ "Content-Type": "application/json",
1497
+ },
1498
+ method: "POST",
1499
+ });
1500
+
1501
+ expect(response.status).toBe(401);
1502
+
1503
+ const errorResponse = (await response.json()) as {
1504
+ error: { code: number; message: string };
1505
+ id: null | number;
1506
+ jsonrpc: string;
1507
+ };
1508
+ expect(errorResponse.error.message).toContain("Token");
1509
+
1510
+ await httpServer.close();
1511
+ });
1512
+
1513
+ it("returns 401 when createServer throws Unauthorized error", async () => {
1514
+ const port = await getRandomPort();
1515
+
1516
+ const httpServer = await startHTTPServer({
1517
+ createServer: async () => {
1518
+ throw new Error("Unauthorized access");
1519
+ },
1520
+ port,
1521
+ stateless: true,
1522
+ });
1523
+
1524
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1525
+ body: JSON.stringify({
1526
+ id: 1,
1527
+ jsonrpc: "2.0",
1528
+ method: "initialize",
1529
+ params: {
1530
+ capabilities: {},
1531
+ clientInfo: { name: "test", version: "1.0.0" },
1532
+ protocolVersion: "2024-11-05",
1533
+ },
1534
+ }),
1535
+ headers: {
1536
+ "Accept": "application/json, text/event-stream",
1537
+ "Content-Type": "application/json",
1538
+ },
1539
+ method: "POST",
1540
+ });
1541
+
1542
+ expect(response.status).toBe(401);
1543
+
1544
+ const errorResponse = (await response.json()) as {
1545
+ error: { code: number; message: string };
1546
+ id: null | number;
1547
+ jsonrpc: string;
1548
+ };
1549
+ expect(errorResponse.error.message).toContain("Unauthorized");
1550
+
1551
+ await httpServer.close();
1552
+ });
1553
+
1554
+ it("returns 500 when createServer throws non-auth error", async () => {
1555
+ const port = await getRandomPort();
1556
+
1557
+ const httpServer = await startHTTPServer({
1558
+ createServer: async () => {
1559
+ throw new Error("Database connection failed");
1560
+ },
1561
+ port,
1562
+ stateless: true,
1563
+ });
1564
+
1565
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1566
+ body: JSON.stringify({
1567
+ id: 1,
1568
+ jsonrpc: "2.0",
1569
+ method: "initialize",
1570
+ params: {
1571
+ capabilities: {},
1572
+ clientInfo: { name: "test", version: "1.0.0" },
1573
+ protocolVersion: "2024-11-05",
1574
+ },
1575
+ }),
1576
+ headers: {
1577
+ "Accept": "application/json, text/event-stream",
1578
+ "Content-Type": "application/json",
1579
+ },
1580
+ method: "POST",
1581
+ });
1582
+
1583
+ expect(response.status).toBe(500);
1584
+
1585
+ await httpServer.close();
1586
+ });
1587
+
1588
+ it("succeeds when authenticate returns { authenticated: true } in stateless mode", async () => {
1589
+ const stdioTransport = new StdioClientTransport({
1590
+ args: ["src/fixtures/simple-stdio-server.ts"],
1591
+ command: "tsx",
1592
+ });
1593
+
1594
+ const stdioClient = new Client(
1595
+ {
1596
+ name: "mcp-proxy",
1597
+ version: "1.0.0",
1598
+ },
1599
+ {
1600
+ capabilities: {},
1601
+ },
1602
+ );
1603
+
1604
+ await stdioClient.connect(stdioTransport);
1605
+
1606
+ const serverVersion = stdioClient.getServerVersion() as {
1607
+ name: string;
1608
+ version: string;
1609
+ };
1610
+
1611
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
1612
+ capabilities: Record<string, unknown>;
1613
+ };
1614
+
1615
+ const port = await getRandomPort();
1616
+
1617
+ // Mock authenticate callback that returns { authenticated: true }
1618
+ const authenticate = vi.fn().mockResolvedValue({
1619
+ authenticated: true,
1620
+ session: { email: "test@example.com", userId: "user123" },
1621
+ });
1622
+
1623
+ const httpServer = await startHTTPServer({
1624
+ authenticate,
1625
+ createServer: async () => {
1626
+ const mcpServer = new Server(serverVersion, {
1627
+ capabilities: serverCapabilities,
1628
+ });
1629
+
1630
+ await proxyServer({
1631
+ client: stdioClient,
1632
+ server: mcpServer,
1633
+ serverCapabilities,
1634
+ });
1635
+
1636
+ return mcpServer;
1637
+ },
1638
+ port,
1639
+ stateless: true,
1640
+ });
1641
+
1642
+ // Create client with valid Bearer token
1643
+ const streamTransport = new StreamableHTTPClientTransport(
1644
+ new URL(`http://localhost:${port}/mcp`),
1645
+ {
1646
+ requestInit: {
1647
+ headers: {
1648
+ Authorization: "Bearer valid-jwt-token",
1649
+ },
1650
+ },
1651
+ },
1652
+ );
1653
+
1654
+ const streamClient = new Client(
1655
+ {
1656
+ name: "stream-client-auth-true",
1657
+ version: "1.0.0",
1658
+ },
1659
+ {
1660
+ capabilities: {},
1661
+ },
1662
+ );
1663
+
1664
+ // Should connect successfully
1665
+ await streamClient.connect(streamTransport);
1666
+
1667
+ // Should be able to make requests
1668
+ const result = await streamClient.listResources();
1669
+ expect(result.resources).toBeDefined();
1670
+
1671
+ // Verify authenticate callback was called
1672
+ expect(authenticate).toHaveBeenCalled();
1673
+
1674
+ await streamClient.close();
1675
+ await httpServer.close();
1676
+ await stdioClient.close();
1677
+ });