mcp-proxy 5.11.2 → 5.12.1
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 +71 -49
- package/dist/bin/mcp-proxy.js +31 -35
- package/dist/bin/mcp-proxy.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +516 -295
- package/dist/index.js.map +1 -1
- package/dist/{stdio-DQCs94rj.js → stdio-BAyQuMKu.js} +11135 -8848
- package/dist/stdio-BAyQuMKu.js.map +1 -0
- package/jsr.json +1 -1
- package/package.json +19 -19
- package/src/InMemoryEventStore.test.ts +58 -50
- package/src/InMemoryEventStore.ts +2 -3
- package/src/authentication.test.ts +275 -18
- package/src/authentication.ts +57 -5
- package/src/bin/mcp-proxy.ts +2 -1
- package/src/proxyServer.test.ts +40 -22
- package/src/startHTTPServer.test.ts +41 -23
- package/src/startHTTPServer.ts +132 -52
- package/src/startStdioServer.test.ts +8 -5
- package/dist/stdio-DQCs94rj.js.map +0 -1
package/src/authentication.ts
CHANGED
|
@@ -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;
|
|
@@ -38,15 +83,21 @@ export class AuthenticationMiddleware {
|
|
|
38
83
|
|
|
39
84
|
// Add resource_metadata if configured
|
|
40
85
|
if (this.config.oauth.protectedResource?.resource) {
|
|
41
|
-
params.push(
|
|
86
|
+
params.push(
|
|
87
|
+
`resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`,
|
|
88
|
+
);
|
|
42
89
|
}
|
|
43
90
|
|
|
44
91
|
// Add error from options or config (options takes precedence)
|
|
45
|
-
const error =
|
|
92
|
+
const error =
|
|
93
|
+
options?.error || this.config.oauth.error || "invalid_token";
|
|
46
94
|
params.push(`error="${error}"`);
|
|
47
95
|
|
|
48
96
|
// Add error_description from options or config (options takes precedence)
|
|
49
|
-
const error_description =
|
|
97
|
+
const error_description =
|
|
98
|
+
options?.error_description ||
|
|
99
|
+
this.config.oauth.error_description ||
|
|
100
|
+
"Unauthorized: Invalid or missing API key";
|
|
50
101
|
// Escape quotes in error description
|
|
51
102
|
const escaped = error_description.replace(/"/g, '\\"');
|
|
52
103
|
params.push(`error_description="${escaped}"`);
|
|
@@ -72,7 +123,9 @@ export class AuthenticationMiddleware {
|
|
|
72
123
|
body: JSON.stringify({
|
|
73
124
|
error: {
|
|
74
125
|
code: 401,
|
|
75
|
-
message:
|
|
126
|
+
message:
|
|
127
|
+
options?.error_description ||
|
|
128
|
+
"Unauthorized: Invalid or missing API key",
|
|
76
129
|
},
|
|
77
130
|
id: null,
|
|
78
131
|
jsonrpc: "2.0",
|
|
@@ -98,4 +151,3 @@ export class AuthenticationMiddleware {
|
|
|
98
151
|
return apiKey === this.config.apiKey;
|
|
99
152
|
}
|
|
100
153
|
}
|
|
101
|
-
|
package/src/bin/mcp-proxy.ts
CHANGED
|
@@ -68,7 +68,8 @@ const argv = await yargs(hideBin(process.argv))
|
|
|
68
68
|
},
|
|
69
69
|
requestTimeout: {
|
|
70
70
|
default: 300000,
|
|
71
|
-
describe:
|
|
71
|
+
describe:
|
|
72
|
+
"The timeout (in milliseconds) for requests to the MCP server (default: 5 minutes)",
|
|
72
73
|
type: "number",
|
|
73
74
|
},
|
|
74
75
|
server: {
|
package/src/proxyServer.test.ts
CHANGED
|
@@ -28,33 +28,47 @@ interface TestEnvironment {
|
|
|
28
28
|
streamClient: Client;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async function createTestEnvironment(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
async function createTestEnvironment(
|
|
32
|
+
config: TestConfig = {},
|
|
33
|
+
): Promise<TestEnvironment> {
|
|
34
|
+
const {
|
|
35
|
+
requestTimeout,
|
|
36
|
+
serverDelay,
|
|
37
|
+
serverFixture = "simple-stdio-server.ts",
|
|
36
38
|
} = config;
|
|
37
|
-
|
|
39
|
+
|
|
38
40
|
const stdioTransport = new StdioClientTransport({
|
|
39
41
|
args: [`src/fixtures/${serverFixture}`],
|
|
40
42
|
command: "tsx",
|
|
41
|
-
env: serverDelay
|
|
43
|
+
env: serverDelay
|
|
44
|
+
? ({ ...process.env, RESPONSE_DELAY: serverDelay } as Record<
|
|
45
|
+
string,
|
|
46
|
+
string
|
|
47
|
+
>)
|
|
48
|
+
: (process.env as Record<string, string>),
|
|
42
49
|
});
|
|
43
50
|
|
|
44
51
|
const stdioClient = new Client(
|
|
45
52
|
{ name: "mcp-proxy-test", version: "1.0.0" },
|
|
46
|
-
{ capabilities: {} }
|
|
53
|
+
{ capabilities: {} },
|
|
47
54
|
);
|
|
48
55
|
|
|
49
56
|
await stdioClient.connect(stdioTransport);
|
|
50
57
|
|
|
51
|
-
const serverVersion = stdioClient.getServerVersion() as {
|
|
52
|
-
|
|
58
|
+
const serverVersion = stdioClient.getServerVersion() as {
|
|
59
|
+
name: string;
|
|
60
|
+
version: string;
|
|
61
|
+
};
|
|
62
|
+
const serverCapabilities = stdioClient.getServerCapabilities() as {
|
|
63
|
+
capabilities: Record<string, unknown>;
|
|
64
|
+
};
|
|
53
65
|
const port = await getRandomPort();
|
|
54
66
|
|
|
55
67
|
const httpServer = await startHTTPServer({
|
|
56
68
|
createServer: async () => {
|
|
57
|
-
const mcpServer = new Server(serverVersion, {
|
|
69
|
+
const mcpServer = new Server(serverVersion, {
|
|
70
|
+
capabilities: serverCapabilities,
|
|
71
|
+
});
|
|
58
72
|
await proxyServer({
|
|
59
73
|
client: stdioClient,
|
|
60
74
|
requestTimeout,
|
|
@@ -68,20 +82,22 @@ async function createTestEnvironment(config: TestConfig = {}): Promise<TestEnvir
|
|
|
68
82
|
|
|
69
83
|
const streamClient = new Client(
|
|
70
84
|
{ name: "stream-client", version: "1.0.0" },
|
|
71
|
-
{ capabilities: {} }
|
|
85
|
+
{ capabilities: {} },
|
|
72
86
|
);
|
|
73
87
|
|
|
74
|
-
const transport = new StreamableHTTPClientTransport(
|
|
88
|
+
const transport = new StreamableHTTPClientTransport(
|
|
89
|
+
new URL(`http://localhost:${port}/mcp`),
|
|
90
|
+
);
|
|
75
91
|
await streamClient.connect(transport);
|
|
76
92
|
|
|
77
|
-
return {
|
|
93
|
+
return {
|
|
78
94
|
cleanup: async () => {
|
|
79
95
|
await streamClient.close();
|
|
80
96
|
await stdioClient.close();
|
|
81
|
-
},
|
|
82
|
-
httpServer,
|
|
83
|
-
stdioClient,
|
|
84
|
-
streamClient
|
|
97
|
+
},
|
|
98
|
+
httpServer,
|
|
99
|
+
stdioClient,
|
|
100
|
+
streamClient,
|
|
85
101
|
};
|
|
86
102
|
}
|
|
87
103
|
|
|
@@ -90,7 +106,7 @@ describe("proxyServer timeout functionality", () => {
|
|
|
90
106
|
const { cleanup, streamClient } = await createTestEnvironment({
|
|
91
107
|
requestTimeout: 1000,
|
|
92
108
|
serverDelay: "500",
|
|
93
|
-
serverFixture: "slow-stdio-server.ts"
|
|
109
|
+
serverFixture: "slow-stdio-server.ts",
|
|
94
110
|
});
|
|
95
111
|
|
|
96
112
|
// This should succeed as timeout (1s) > delay (500ms)
|
|
@@ -105,7 +121,7 @@ describe("proxyServer timeout functionality", () => {
|
|
|
105
121
|
const { cleanup, streamClient } = await createTestEnvironment({
|
|
106
122
|
requestTimeout: 500,
|
|
107
123
|
serverDelay: "1000",
|
|
108
|
-
serverFixture: "slow-stdio-server.ts"
|
|
124
|
+
serverFixture: "slow-stdio-server.ts",
|
|
109
125
|
});
|
|
110
126
|
|
|
111
127
|
// This should throw a timeout error as delay (1s) > timeout (500ms)
|
|
@@ -128,7 +144,7 @@ describe("proxyServer timeout functionality", () => {
|
|
|
128
144
|
const { cleanup, streamClient } = await createTestEnvironment({
|
|
129
145
|
requestTimeout: 600,
|
|
130
146
|
serverDelay: "300",
|
|
131
|
-
serverFixture: "slow-stdio-server.ts"
|
|
147
|
+
serverFixture: "slow-stdio-server.ts",
|
|
132
148
|
});
|
|
133
149
|
|
|
134
150
|
// First get the resources
|
|
@@ -141,7 +157,9 @@ describe("proxyServer timeout functionality", () => {
|
|
|
141
157
|
});
|
|
142
158
|
|
|
143
159
|
expect(resourceContent.contents).toBeDefined();
|
|
144
|
-
expect(resourceContent.contents[0].text).toContain(
|
|
160
|
+
expect((resourceContent.contents[0] as { text: string }).text).toContain(
|
|
161
|
+
"300ms delay",
|
|
162
|
+
);
|
|
145
163
|
|
|
146
164
|
await cleanup();
|
|
147
165
|
}, 10000);
|
|
@@ -1339,8 +1339,8 @@ it("returns 401 with custom error message when { authenticated: false, error: '.
|
|
|
1339
1339
|
},
|
|
1340
1340
|
}),
|
|
1341
1341
|
headers: {
|
|
1342
|
-
|
|
1343
|
-
|
|
1342
|
+
Accept: "application/json, text/event-stream",
|
|
1343
|
+
Authorization: "Bearer expired-token",
|
|
1344
1344
|
"Content-Type": "application/json",
|
|
1345
1345
|
},
|
|
1346
1346
|
method: "POST",
|
|
@@ -1408,8 +1408,8 @@ it("returns 401 when createServer throws authentication error", async () => {
|
|
|
1408
1408
|
},
|
|
1409
1409
|
}),
|
|
1410
1410
|
headers: {
|
|
1411
|
-
|
|
1412
|
-
|
|
1411
|
+
Accept: "application/json, text/event-stream",
|
|
1412
|
+
Authorization: "Bearer test-token",
|
|
1413
1413
|
"Content-Type": "application/json",
|
|
1414
1414
|
},
|
|
1415
1415
|
method: "POST",
|
|
@@ -1451,7 +1451,7 @@ it("returns 401 when createServer throws JWT-related error", async () => {
|
|
|
1451
1451
|
},
|
|
1452
1452
|
}),
|
|
1453
1453
|
headers: {
|
|
1454
|
-
|
|
1454
|
+
Accept: "application/json, text/event-stream",
|
|
1455
1455
|
"Content-Type": "application/json",
|
|
1456
1456
|
},
|
|
1457
1457
|
method: "POST",
|
|
@@ -1492,7 +1492,7 @@ it("returns 401 when createServer throws Token-related error", async () => {
|
|
|
1492
1492
|
},
|
|
1493
1493
|
}),
|
|
1494
1494
|
headers: {
|
|
1495
|
-
|
|
1495
|
+
Accept: "application/json, text/event-stream",
|
|
1496
1496
|
"Content-Type": "application/json",
|
|
1497
1497
|
},
|
|
1498
1498
|
method: "POST",
|
|
@@ -1533,7 +1533,7 @@ it("returns 401 when createServer throws Unauthorized error", async () => {
|
|
|
1533
1533
|
},
|
|
1534
1534
|
}),
|
|
1535
1535
|
headers: {
|
|
1536
|
-
|
|
1536
|
+
Accept: "application/json, text/event-stream",
|
|
1537
1537
|
"Content-Type": "application/json",
|
|
1538
1538
|
},
|
|
1539
1539
|
method: "POST",
|
|
@@ -1574,7 +1574,7 @@ it("returns 500 when createServer throws non-auth error", async () => {
|
|
|
1574
1574
|
},
|
|
1575
1575
|
}),
|
|
1576
1576
|
headers: {
|
|
1577
|
-
|
|
1577
|
+
Accept: "application/json, text/event-stream",
|
|
1578
1578
|
"Content-Type": "application/json",
|
|
1579
1579
|
},
|
|
1580
1580
|
method: "POST",
|
|
@@ -1614,7 +1614,7 @@ it("includes WWW-Authenticate header in 401 response with OAuth config", async (
|
|
|
1614
1614
|
},
|
|
1615
1615
|
}),
|
|
1616
1616
|
headers: {
|
|
1617
|
-
|
|
1617
|
+
Accept: "application/json, text/event-stream",
|
|
1618
1618
|
"Content-Type": "application/json",
|
|
1619
1619
|
},
|
|
1620
1620
|
method: "POST",
|
|
@@ -1624,9 +1624,11 @@ it("includes WWW-Authenticate header in 401 response with OAuth config", async (
|
|
|
1624
1624
|
|
|
1625
1625
|
const wwwAuthHeader = response.headers.get("WWW-Authenticate");
|
|
1626
1626
|
expect(wwwAuthHeader).toBeTruthy();
|
|
1627
|
-
expect(wwwAuthHeader).toContain(
|
|
1627
|
+
expect(wwwAuthHeader).toContain("Bearer");
|
|
1628
1628
|
expect(wwwAuthHeader).toContain('realm="mcp-server"');
|
|
1629
|
-
expect(wwwAuthHeader).toContain(
|
|
1629
|
+
expect(wwwAuthHeader).toContain(
|
|
1630
|
+
'resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
|
|
1631
|
+
);
|
|
1630
1632
|
expect(wwwAuthHeader).toContain('error="invalid_token"');
|
|
1631
1633
|
expect(wwwAuthHeader).toContain('error_description="Invalid JWT token"');
|
|
1632
1634
|
|
|
@@ -1636,7 +1638,9 @@ it("includes WWW-Authenticate header in 401 response with OAuth config", async (
|
|
|
1636
1638
|
it("includes WWW-Authenticate header when authenticate callback fails with OAuth", async () => {
|
|
1637
1639
|
const port = await getRandomPort();
|
|
1638
1640
|
|
|
1639
|
-
const authenticate = vi
|
|
1641
|
+
const authenticate = vi
|
|
1642
|
+
.fn()
|
|
1643
|
+
.mockRejectedValue(new Error("Token signature verification failed"));
|
|
1640
1644
|
|
|
1641
1645
|
const httpServer = await startHTTPServer({
|
|
1642
1646
|
authenticate,
|
|
@@ -1670,8 +1674,8 @@ it("includes WWW-Authenticate header when authenticate callback fails with OAuth
|
|
|
1670
1674
|
},
|
|
1671
1675
|
}),
|
|
1672
1676
|
headers: {
|
|
1673
|
-
|
|
1674
|
-
|
|
1677
|
+
Accept: "application/json, text/event-stream",
|
|
1678
|
+
Authorization: "Bearer expired-token",
|
|
1675
1679
|
"Content-Type": "application/json",
|
|
1676
1680
|
},
|
|
1677
1681
|
method: "POST",
|
|
@@ -1682,12 +1686,18 @@ it("includes WWW-Authenticate header when authenticate callback fails with OAuth
|
|
|
1682
1686
|
|
|
1683
1687
|
const wwwAuthHeader = response.headers.get("WWW-Authenticate");
|
|
1684
1688
|
expect(wwwAuthHeader).toBeTruthy();
|
|
1685
|
-
expect(wwwAuthHeader).toContain(
|
|
1689
|
+
expect(wwwAuthHeader).toContain("Bearer");
|
|
1686
1690
|
expect(wwwAuthHeader).toContain('realm="example-api"');
|
|
1687
|
-
expect(wwwAuthHeader).toContain(
|
|
1691
|
+
expect(wwwAuthHeader).toContain(
|
|
1692
|
+
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"',
|
|
1693
|
+
);
|
|
1688
1694
|
expect(wwwAuthHeader).toContain('error="invalid_token"');
|
|
1689
|
-
expect(wwwAuthHeader).toContain(
|
|
1690
|
-
|
|
1695
|
+
expect(wwwAuthHeader).toContain(
|
|
1696
|
+
'error_description="Token signature verification failed"',
|
|
1697
|
+
);
|
|
1698
|
+
expect(wwwAuthHeader).toContain(
|
|
1699
|
+
'error_uri="https://example.com/docs/errors"',
|
|
1700
|
+
);
|
|
1691
1701
|
|
|
1692
1702
|
await httpServer.close();
|
|
1693
1703
|
});
|
|
@@ -1715,7 +1725,7 @@ it("does not include WWW-Authenticate header in 401 response without OAuth confi
|
|
|
1715
1725
|
},
|
|
1716
1726
|
}),
|
|
1717
1727
|
headers: {
|
|
1718
|
-
|
|
1728
|
+
Accept: "application/json, text/event-stream",
|
|
1719
1729
|
"Content-Type": "application/json",
|
|
1720
1730
|
},
|
|
1721
1731
|
method: "POST",
|
|
@@ -1916,7 +1926,9 @@ it("supports origin validation with array", async () => {
|
|
|
1916
1926
|
});
|
|
1917
1927
|
|
|
1918
1928
|
expect(response1.status).toBe(204);
|
|
1919
|
-
expect(response1.headers.get("Access-Control-Allow-Origin")).toBe(
|
|
1929
|
+
expect(response1.headers.get("Access-Control-Allow-Origin")).toBe(
|
|
1930
|
+
"https://app.example.com",
|
|
1931
|
+
);
|
|
1920
1932
|
|
|
1921
1933
|
// Test with disallowed origin
|
|
1922
1934
|
const response2 = await fetch(`http://localhost:${port}/mcp`, {
|
|
@@ -1958,7 +1970,9 @@ it("supports origin validation with function", async () => {
|
|
|
1958
1970
|
});
|
|
1959
1971
|
|
|
1960
1972
|
expect(response1.status).toBe(204);
|
|
1961
|
-
expect(response1.headers.get("Access-Control-Allow-Origin")).toBe(
|
|
1973
|
+
expect(response1.headers.get("Access-Control-Allow-Origin")).toBe(
|
|
1974
|
+
"https://subdomain.example.com",
|
|
1975
|
+
);
|
|
1962
1976
|
|
|
1963
1977
|
// Test with disallowed origin
|
|
1964
1978
|
const response2 = await fetch(`http://localhost:${port}/mcp`, {
|
|
@@ -2029,7 +2043,9 @@ it("uses default CORS settings when cors: true", async () => {
|
|
|
2029
2043
|
|
|
2030
2044
|
expect(response.status).toBe(204);
|
|
2031
2045
|
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
|
2032
|
-
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
|
|
2046
|
+
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
|
|
2047
|
+
"Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-Id",
|
|
2048
|
+
);
|
|
2033
2049
|
expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
|
2034
2050
|
|
|
2035
2051
|
await httpServer.close();
|
|
@@ -2062,7 +2078,9 @@ it("supports custom methods and maxAge", async () => {
|
|
|
2062
2078
|
});
|
|
2063
2079
|
|
|
2064
2080
|
expect(response.status).toBe(204);
|
|
2065
|
-
expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
|
|
2081
|
+
expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
|
|
2082
|
+
"GET, POST, PUT, DELETE",
|
|
2083
|
+
);
|
|
2066
2084
|
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
|
|
2067
2085
|
|
|
2068
2086
|
await httpServer.close();
|