@bryan-thompson/inspector-assessment-server 1.25.1 → 1.25.4
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/build/__tests__/helpers.test.js +327 -0
- package/build/__tests__/routes.test.js +197 -0
- package/build/__tests__/security.test.js +162 -0
- package/build/helpers.js +112 -0
- package/build/index.js +9 -105
- package/package.json +8 -2
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Helper Functions Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for pure functions: is401Error, getHttpHeaders, updateHeadersInPlace
|
|
5
|
+
*/
|
|
6
|
+
import { jest, describe, it, expect } from "@jest/globals";
|
|
7
|
+
import { is401Error, getHttpHeaders, updateHeadersInPlace, } from "../helpers.js";
|
|
8
|
+
import { SseError } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
9
|
+
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
10
|
+
/**
|
|
11
|
+
* Create a mock ErrorEvent for SseError constructor
|
|
12
|
+
* ErrorEvent is a browser API not available in Node.js, so we create a minimal mock
|
|
13
|
+
*/
|
|
14
|
+
const createMockErrorEvent = (message = "error") => {
|
|
15
|
+
// Create a minimal mock that satisfies the ErrorEvent interface
|
|
16
|
+
return {
|
|
17
|
+
type: "error",
|
|
18
|
+
message,
|
|
19
|
+
bubbles: false,
|
|
20
|
+
cancelable: false,
|
|
21
|
+
composed: false,
|
|
22
|
+
defaultPrevented: false,
|
|
23
|
+
eventPhase: 0,
|
|
24
|
+
isTrusted: false,
|
|
25
|
+
returnValue: true,
|
|
26
|
+
srcElement: null,
|
|
27
|
+
target: null,
|
|
28
|
+
currentTarget: null,
|
|
29
|
+
timeStamp: Date.now(),
|
|
30
|
+
cancelBubble: false,
|
|
31
|
+
NONE: 0,
|
|
32
|
+
CAPTURING_PHASE: 1,
|
|
33
|
+
AT_TARGET: 2,
|
|
34
|
+
BUBBLING_PHASE: 3,
|
|
35
|
+
colno: 0,
|
|
36
|
+
lineno: 0,
|
|
37
|
+
filename: "",
|
|
38
|
+
error: null,
|
|
39
|
+
composedPath: () => [],
|
|
40
|
+
initEvent: () => { },
|
|
41
|
+
preventDefault: () => { },
|
|
42
|
+
stopImmediatePropagation: () => { },
|
|
43
|
+
stopPropagation: () => { },
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Helper to create a mock Express request with headers
|
|
48
|
+
*/
|
|
49
|
+
const createMockRequest = (headers) => ({
|
|
50
|
+
headers: headers,
|
|
51
|
+
});
|
|
52
|
+
describe("is401Error", () => {
|
|
53
|
+
describe("SseError detection", () => {
|
|
54
|
+
it("should return true for SseError with code 401", () => {
|
|
55
|
+
const error = new SseError(401, "Unauthorized", createMockErrorEvent());
|
|
56
|
+
expect(is401Error(error)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
it("should return false for SseError with non-401 code", () => {
|
|
59
|
+
const error = new SseError(404, "Not Found", createMockErrorEvent());
|
|
60
|
+
expect(is401Error(error)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe("StreamableHTTPError detection", () => {
|
|
64
|
+
it("should return true for StreamableHTTPError with code 401", () => {
|
|
65
|
+
const error = new StreamableHTTPError(401, "Unauthorized");
|
|
66
|
+
expect(is401Error(error)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it("should return false for StreamableHTTPError with non-401 code", () => {
|
|
69
|
+
const error = new StreamableHTTPError(500, "Internal Server Error");
|
|
70
|
+
expect(is401Error(error)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("Generic Error detection", () => {
|
|
74
|
+
it("should return true for Error containing 'HTTP 401'", () => {
|
|
75
|
+
const error = new Error("HTTP 401 Unauthorized");
|
|
76
|
+
expect(is401Error(error)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it("should return true for Error containing '(401)'", () => {
|
|
79
|
+
const error = new Error("Server returned (401) unauthorized");
|
|
80
|
+
expect(is401Error(error)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
it("should return false for Error with other codes", () => {
|
|
83
|
+
const error = new Error("HTTP 500 Internal Server Error");
|
|
84
|
+
expect(is401Error(error)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
it("should return false for generic error without 401", () => {
|
|
87
|
+
const error = new Error("Something went wrong");
|
|
88
|
+
expect(is401Error(error)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("Non-Error values", () => {
|
|
92
|
+
it("should return false for null", () => {
|
|
93
|
+
expect(is401Error(null)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it("should return false for undefined", () => {
|
|
96
|
+
expect(is401Error(undefined)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it("should return false for string", () => {
|
|
99
|
+
expect(is401Error("HTTP 401")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
it("should return false for number", () => {
|
|
102
|
+
expect(is401Error(401)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
it("should return false for plain object", () => {
|
|
105
|
+
expect(is401Error({ code: 401 })).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("getHttpHeaders", () => {
|
|
110
|
+
describe("MCP header forwarding", () => {
|
|
111
|
+
it("should forward mcp-* headers", () => {
|
|
112
|
+
const req = createMockRequest({
|
|
113
|
+
"mcp-custom-header": "custom-value",
|
|
114
|
+
"mcp-another": "another-value",
|
|
115
|
+
});
|
|
116
|
+
const headers = getHttpHeaders(req);
|
|
117
|
+
expect(headers["mcp-custom-header"]).toBe("custom-value");
|
|
118
|
+
expect(headers["mcp-another"]).toBe("another-value");
|
|
119
|
+
});
|
|
120
|
+
it("should forward authorization header", () => {
|
|
121
|
+
const req = createMockRequest({
|
|
122
|
+
authorization: "Bearer token123",
|
|
123
|
+
});
|
|
124
|
+
const headers = getHttpHeaders(req);
|
|
125
|
+
expect(headers["authorization"]).toBe("Bearer token123");
|
|
126
|
+
});
|
|
127
|
+
it("should forward last-event-id header", () => {
|
|
128
|
+
const req = createMockRequest({
|
|
129
|
+
"last-event-id": "event-42",
|
|
130
|
+
});
|
|
131
|
+
const headers = getHttpHeaders(req);
|
|
132
|
+
expect(headers["last-event-id"]).toBe("event-42");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe("Header exclusions", () => {
|
|
136
|
+
it("should exclude x-mcp-proxy-auth header", () => {
|
|
137
|
+
const req = createMockRequest({
|
|
138
|
+
"x-mcp-proxy-auth": "Bearer secret",
|
|
139
|
+
"mcp-other": "value",
|
|
140
|
+
});
|
|
141
|
+
const headers = getHttpHeaders(req);
|
|
142
|
+
expect(headers["x-mcp-proxy-auth"]).toBeUndefined();
|
|
143
|
+
expect(headers["mcp-other"]).toBe("value");
|
|
144
|
+
});
|
|
145
|
+
it("should exclude mcp-session-id header", () => {
|
|
146
|
+
const req = createMockRequest({
|
|
147
|
+
"mcp-session-id": "session-123",
|
|
148
|
+
"mcp-other": "value",
|
|
149
|
+
});
|
|
150
|
+
const headers = getHttpHeaders(req);
|
|
151
|
+
expect(headers["mcp-session-id"]).toBeUndefined();
|
|
152
|
+
expect(headers["mcp-other"]).toBe("value");
|
|
153
|
+
});
|
|
154
|
+
it("should not forward non-mcp headers", () => {
|
|
155
|
+
const req = createMockRequest({
|
|
156
|
+
"content-type": "application/json",
|
|
157
|
+
"x-custom-header": "value",
|
|
158
|
+
host: "localhost:3000",
|
|
159
|
+
});
|
|
160
|
+
const headers = getHttpHeaders(req);
|
|
161
|
+
expect(headers["content-type"]).toBeUndefined();
|
|
162
|
+
expect(headers["x-custom-header"]).toBeUndefined();
|
|
163
|
+
expect(headers["host"]).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("Array header values", () => {
|
|
167
|
+
it("should use last element for array header values", () => {
|
|
168
|
+
const req = createMockRequest({
|
|
169
|
+
"mcp-multi": ["first", "second", "last"],
|
|
170
|
+
});
|
|
171
|
+
const headers = getHttpHeaders(req);
|
|
172
|
+
expect(headers["mcp-multi"]).toBe("last");
|
|
173
|
+
});
|
|
174
|
+
it("should handle empty array gracefully", () => {
|
|
175
|
+
const req = createMockRequest({
|
|
176
|
+
"mcp-empty": [],
|
|
177
|
+
});
|
|
178
|
+
const headers = getHttpHeaders(req);
|
|
179
|
+
expect(headers["mcp-empty"]).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe("Custom auth header (x-custom-auth-header)", () => {
|
|
183
|
+
it("should forward custom auth header when specified", () => {
|
|
184
|
+
const req = createMockRequest({
|
|
185
|
+
"x-custom-auth-header": "X-API-Key",
|
|
186
|
+
"x-api-key": "secret-key-123",
|
|
187
|
+
});
|
|
188
|
+
const headers = getHttpHeaders(req);
|
|
189
|
+
expect(headers["X-API-Key"]).toBe("secret-key-123");
|
|
190
|
+
});
|
|
191
|
+
it("should handle array values for custom auth header", () => {
|
|
192
|
+
const req = createMockRequest({
|
|
193
|
+
"x-custom-auth-header": "X-Token",
|
|
194
|
+
"x-token": ["old-token", "new-token"],
|
|
195
|
+
});
|
|
196
|
+
const headers = getHttpHeaders(req);
|
|
197
|
+
expect(headers["X-Token"]).toBe("new-token");
|
|
198
|
+
});
|
|
199
|
+
it("should ignore if custom auth header value not present", () => {
|
|
200
|
+
const req = createMockRequest({
|
|
201
|
+
"x-custom-auth-header": "X-Missing",
|
|
202
|
+
});
|
|
203
|
+
const headers = getHttpHeaders(req);
|
|
204
|
+
expect(headers["X-Missing"]).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
describe("Multiple custom headers (x-custom-auth-headers)", () => {
|
|
208
|
+
it("should forward multiple custom headers from JSON array", () => {
|
|
209
|
+
const req = createMockRequest({
|
|
210
|
+
"x-custom-auth-headers": JSON.stringify(["X-API-Key", "X-Client-ID"]),
|
|
211
|
+
"x-api-key": "api-key-value",
|
|
212
|
+
"x-client-id": "client-123",
|
|
213
|
+
});
|
|
214
|
+
const headers = getHttpHeaders(req);
|
|
215
|
+
expect(headers["X-API-Key"]).toBe("api-key-value");
|
|
216
|
+
expect(headers["X-Client-ID"]).toBe("client-123");
|
|
217
|
+
});
|
|
218
|
+
it("should handle array values in custom headers", () => {
|
|
219
|
+
const req = createMockRequest({
|
|
220
|
+
"x-custom-auth-headers": JSON.stringify(["X-Token"]),
|
|
221
|
+
"x-token": ["first", "second"],
|
|
222
|
+
});
|
|
223
|
+
const headers = getHttpHeaders(req);
|
|
224
|
+
expect(headers["X-Token"]).toBe("second");
|
|
225
|
+
});
|
|
226
|
+
it("should handle malformed JSON gracefully", () => {
|
|
227
|
+
const consoleSpy = jest
|
|
228
|
+
.spyOn(console, "warn")
|
|
229
|
+
.mockImplementation(() => { });
|
|
230
|
+
const req = createMockRequest({
|
|
231
|
+
"x-custom-auth-headers": "not-valid-json",
|
|
232
|
+
});
|
|
233
|
+
const headers = getHttpHeaders(req);
|
|
234
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
235
|
+
expect(Object.keys(headers)).toHaveLength(0);
|
|
236
|
+
consoleSpy.mockRestore();
|
|
237
|
+
});
|
|
238
|
+
it("should ignore non-array JSON values", () => {
|
|
239
|
+
const req = createMockRequest({
|
|
240
|
+
"x-custom-auth-headers": JSON.stringify({ key: "value" }),
|
|
241
|
+
});
|
|
242
|
+
// Should not throw, just not add any headers
|
|
243
|
+
const headers = getHttpHeaders(req);
|
|
244
|
+
expect(headers).toEqual({});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe("Empty and undefined values", () => {
|
|
248
|
+
it("should handle empty request headers", () => {
|
|
249
|
+
const req = createMockRequest({});
|
|
250
|
+
const headers = getHttpHeaders(req);
|
|
251
|
+
expect(headers).toEqual({});
|
|
252
|
+
});
|
|
253
|
+
it("should skip undefined header values", () => {
|
|
254
|
+
const req = createMockRequest({
|
|
255
|
+
"mcp-defined": "value",
|
|
256
|
+
"mcp-undefined": undefined,
|
|
257
|
+
});
|
|
258
|
+
const headers = getHttpHeaders(req);
|
|
259
|
+
expect(headers["mcp-defined"]).toBe("value");
|
|
260
|
+
expect(headers["mcp-undefined"]).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
describe("updateHeadersInPlace", () => {
|
|
265
|
+
it("should replace all headers with new ones", () => {
|
|
266
|
+
const current = {
|
|
267
|
+
"Old-Header": "old-value",
|
|
268
|
+
Another: "another",
|
|
269
|
+
};
|
|
270
|
+
const updated = { "New-Header": "new-value" };
|
|
271
|
+
updateHeadersInPlace(current, updated);
|
|
272
|
+
expect(current).toEqual({ "New-Header": "new-value" });
|
|
273
|
+
expect(current["Old-Header"]).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
it("should preserve Accept header when present", () => {
|
|
276
|
+
const current = {
|
|
277
|
+
Accept: "text/event-stream",
|
|
278
|
+
"Old-Header": "old",
|
|
279
|
+
};
|
|
280
|
+
const updated = { "New-Header": "new" };
|
|
281
|
+
updateHeadersInPlace(current, updated);
|
|
282
|
+
expect(current).toEqual({
|
|
283
|
+
Accept: "text/event-stream",
|
|
284
|
+
"New-Header": "new",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
it("should not add Accept header if not originally present", () => {
|
|
288
|
+
const current = { "Old-Header": "old" };
|
|
289
|
+
const updated = { "New-Header": "new" };
|
|
290
|
+
updateHeadersInPlace(current, updated);
|
|
291
|
+
expect(current).toEqual({ "New-Header": "new" });
|
|
292
|
+
expect(current["Accept"]).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
it("should handle empty current headers", () => {
|
|
295
|
+
const current = {};
|
|
296
|
+
const updated = { "New-Header": "new" };
|
|
297
|
+
updateHeadersInPlace(current, updated);
|
|
298
|
+
expect(current).toEqual({ "New-Header": "new" });
|
|
299
|
+
});
|
|
300
|
+
it("should handle empty new headers", () => {
|
|
301
|
+
const current = { "Old-Header": "old" };
|
|
302
|
+
const updated = {};
|
|
303
|
+
updateHeadersInPlace(current, updated);
|
|
304
|
+
expect(current).toEqual({});
|
|
305
|
+
});
|
|
306
|
+
it("should preserve Accept even when updating with new Accept", () => {
|
|
307
|
+
const current = {
|
|
308
|
+
Accept: "original-accept",
|
|
309
|
+
Other: "value",
|
|
310
|
+
};
|
|
311
|
+
const updated = {
|
|
312
|
+
Accept: "new-accept",
|
|
313
|
+
"New-Header": "new",
|
|
314
|
+
};
|
|
315
|
+
updateHeadersInPlace(current, updated);
|
|
316
|
+
// New Accept from updated is applied, then original Accept is restored
|
|
317
|
+
expect(current["Accept"]).toBe("original-accept");
|
|
318
|
+
expect(current["New-Header"]).toBe("new");
|
|
319
|
+
});
|
|
320
|
+
it("should mutate the original object reference", () => {
|
|
321
|
+
const current = { Header: "value" };
|
|
322
|
+
const originalRef = current;
|
|
323
|
+
updateHeadersInPlace(current, { New: "value" });
|
|
324
|
+
expect(current).toBe(originalRef);
|
|
325
|
+
expect(originalRef["New"]).toBe("value");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Route Tests
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for API endpoints using supertest.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeAll, afterEach } from "@jest/globals";
|
|
7
|
+
import request from "supertest";
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
// Set test mode before importing app
|
|
10
|
+
process.env.NODE_ENV = "test";
|
|
11
|
+
// Dynamic import to ensure env vars are set first
|
|
12
|
+
let app;
|
|
13
|
+
let sessionToken;
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
const serverModule = await import("../index.js");
|
|
16
|
+
app = serverModule.app;
|
|
17
|
+
sessionToken = serverModule.sessionToken;
|
|
18
|
+
});
|
|
19
|
+
describe("GET /health", () => {
|
|
20
|
+
it("should return { status: 'ok' }", async () => {
|
|
21
|
+
const response = await request(app)
|
|
22
|
+
.get("/health")
|
|
23
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
24
|
+
expect(response.status).toBe(200);
|
|
25
|
+
expect(response.body).toEqual({ status: "ok" });
|
|
26
|
+
});
|
|
27
|
+
it("should return 200 even without auth (health check accessible)", async () => {
|
|
28
|
+
// Health endpoint may or may not require auth depending on setup
|
|
29
|
+
// Check that it responds with 200 when auth is provided
|
|
30
|
+
const response = await request(app)
|
|
31
|
+
.get("/health")
|
|
32
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
33
|
+
expect(response.status).toBe(200);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("GET /config", () => {
|
|
37
|
+
it("should return server configuration", async () => {
|
|
38
|
+
const response = await request(app)
|
|
39
|
+
.get("/config")
|
|
40
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
41
|
+
expect(response.status).toBe(200);
|
|
42
|
+
expect(response.body).toHaveProperty("defaultEnvironment");
|
|
43
|
+
expect(response.body).toHaveProperty("defaultCommand");
|
|
44
|
+
expect(response.body).toHaveProperty("defaultArgs");
|
|
45
|
+
expect(response.body).toHaveProperty("defaultTransport");
|
|
46
|
+
expect(response.body).toHaveProperty("defaultServerUrl");
|
|
47
|
+
});
|
|
48
|
+
it("should require authentication", async () => {
|
|
49
|
+
const response = await request(app).get("/config");
|
|
50
|
+
expect(response.status).toBe(401);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("POST /assessment/save", () => {
|
|
54
|
+
const testFilePath = "/tmp/inspector-assessment-test_server.json";
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
// Cleanup test file
|
|
57
|
+
if (fs.existsSync(testFilePath)) {
|
|
58
|
+
fs.unlinkSync(testFilePath);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
it("should save assessment to /tmp", async () => {
|
|
62
|
+
const assessment = {
|
|
63
|
+
server: "test_server",
|
|
64
|
+
results: { passed: true },
|
|
65
|
+
timestamp: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
const response = await request(app)
|
|
68
|
+
.post("/assessment/save")
|
|
69
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
70
|
+
.send({ serverName: "test_server", assessment });
|
|
71
|
+
expect(response.status).toBe(200);
|
|
72
|
+
expect(response.body.success).toBe(true);
|
|
73
|
+
expect(response.body.path).toContain("inspector-assessment-test_server.json");
|
|
74
|
+
expect(fs.existsSync(testFilePath)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it("should sanitize server name in filename", async () => {
|
|
77
|
+
const assessment = { test: true };
|
|
78
|
+
const response = await request(app)
|
|
79
|
+
.post("/assessment/save")
|
|
80
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
81
|
+
.send({ serverName: "test@server#!$", assessment });
|
|
82
|
+
expect(response.status).toBe(200);
|
|
83
|
+
expect(response.body.path).toContain("test_server___");
|
|
84
|
+
expect(response.body.path).not.toContain("@");
|
|
85
|
+
expect(response.body.path).not.toContain("#");
|
|
86
|
+
});
|
|
87
|
+
it("should return success with path", async () => {
|
|
88
|
+
const response = await request(app)
|
|
89
|
+
.post("/assessment/save")
|
|
90
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
91
|
+
.send({ serverName: "test_server", assessment: {} });
|
|
92
|
+
expect(response.body).toMatchObject({
|
|
93
|
+
success: true,
|
|
94
|
+
path: expect.stringContaining("/tmp/"),
|
|
95
|
+
message: expect.stringContaining("Assessment saved"),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
it("should handle missing serverName gracefully", async () => {
|
|
99
|
+
const response = await request(app)
|
|
100
|
+
.post("/assessment/save")
|
|
101
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
102
|
+
.send({ assessment: { data: true } });
|
|
103
|
+
expect(response.status).toBe(200);
|
|
104
|
+
expect(response.body.path).toContain("unknown");
|
|
105
|
+
});
|
|
106
|
+
it("should require authentication", async () => {
|
|
107
|
+
const response = await request(app)
|
|
108
|
+
.post("/assessment/save")
|
|
109
|
+
.send({ serverName: "test", assessment: {} });
|
|
110
|
+
expect(response.status).toBe(401);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe("MCP Session Endpoints", () => {
|
|
114
|
+
describe("GET /mcp", () => {
|
|
115
|
+
it("should return 404 for non-existent session", async () => {
|
|
116
|
+
const response = await request(app)
|
|
117
|
+
.get("/mcp")
|
|
118
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
119
|
+
.set("mcp-session-id", "non-existent-session-id");
|
|
120
|
+
expect(response.status).toBe(404);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe("POST /mcp", () => {
|
|
124
|
+
it("should return 404 for non-existent session with sessionId", async () => {
|
|
125
|
+
const response = await request(app)
|
|
126
|
+
.post("/mcp")
|
|
127
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
128
|
+
.set("mcp-session-id", "non-existent-session-id")
|
|
129
|
+
.send({});
|
|
130
|
+
expect(response.status).toBe(404);
|
|
131
|
+
});
|
|
132
|
+
it("should require transport parameters for new session", async () => {
|
|
133
|
+
// Without transportType, should error
|
|
134
|
+
const response = await request(app)
|
|
135
|
+
.post("/mcp")
|
|
136
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
137
|
+
.send({});
|
|
138
|
+
// Will fail due to missing transport config - 500 error
|
|
139
|
+
expect(response.status).toBe(500);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe("DELETE /mcp", () => {
|
|
143
|
+
it("should return 404 for non-existent session", async () => {
|
|
144
|
+
const response = await request(app)
|
|
145
|
+
.delete("/mcp")
|
|
146
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
147
|
+
.set("mcp-session-id", "non-existent-session-id");
|
|
148
|
+
expect(response.status).toBe(404);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe("POST /message", () => {
|
|
152
|
+
it("should return 404 for non-existent session", async () => {
|
|
153
|
+
const response = await request(app)
|
|
154
|
+
.post("/message")
|
|
155
|
+
.query({ sessionId: "non-existent" })
|
|
156
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
157
|
+
.send({});
|
|
158
|
+
expect(response.status).toBe(404);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe("Error Handling", () => {
|
|
163
|
+
it("should return 404 for unknown routes", async () => {
|
|
164
|
+
const response = await request(app)
|
|
165
|
+
.get("/unknown-route")
|
|
166
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
167
|
+
expect(response.status).toBe(404);
|
|
168
|
+
});
|
|
169
|
+
it("should handle malformed JSON in POST body", async () => {
|
|
170
|
+
const response = await request(app)
|
|
171
|
+
.post("/assessment/save")
|
|
172
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
173
|
+
.set("Content-Type", "application/json")
|
|
174
|
+
.send("not-valid-json");
|
|
175
|
+
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
describe("Rate Limiting", () => {
|
|
179
|
+
it("should include rate limit headers on rate-limited endpoints", async () => {
|
|
180
|
+
// Rate limiting is applied to /mcp, /sse, /stdio, /message routes
|
|
181
|
+
// /health is not rate limited, so we test against /mcp
|
|
182
|
+
const response = await request(app)
|
|
183
|
+
.get("/mcp")
|
|
184
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
185
|
+
.set("mcp-session-id", "test-rate-limit-session");
|
|
186
|
+
// Even with 404 response, rate limit headers should be present
|
|
187
|
+
expect(response.headers).toHaveProperty("ratelimit-limit");
|
|
188
|
+
expect(response.headers).toHaveProperty("ratelimit-remaining");
|
|
189
|
+
});
|
|
190
|
+
it("should not include rate limit headers on non-limited endpoints", async () => {
|
|
191
|
+
const response = await request(app)
|
|
192
|
+
.get("/health")
|
|
193
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
194
|
+
// /health is not rate limited
|
|
195
|
+
expect(response.headers["ratelimit-limit"]).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Security Tests
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for authentication middleware, origin validation,
|
|
5
|
+
* and security headers using supertest.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeAll } from "@jest/globals";
|
|
8
|
+
import request from "supertest";
|
|
9
|
+
// Set test mode before importing app
|
|
10
|
+
process.env.NODE_ENV = "test";
|
|
11
|
+
process.env.DANGEROUSLY_OMIT_AUTH = ""; // Ensure auth is enabled
|
|
12
|
+
// Dynamic import to ensure env vars are set first
|
|
13
|
+
let app;
|
|
14
|
+
let sessionToken;
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
const serverModule = await import("../index.js");
|
|
17
|
+
app = serverModule.app;
|
|
18
|
+
sessionToken = serverModule.sessionToken;
|
|
19
|
+
});
|
|
20
|
+
describe("Authentication Middleware", () => {
|
|
21
|
+
describe("Valid Authentication", () => {
|
|
22
|
+
it("should accept requests with valid Bearer token", async () => {
|
|
23
|
+
const response = await request(app)
|
|
24
|
+
.get("/health")
|
|
25
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
26
|
+
expect(response.status).toBe(200);
|
|
27
|
+
expect(response.body.status).toBe("ok");
|
|
28
|
+
});
|
|
29
|
+
it("should accept requests to /config with valid token", async () => {
|
|
30
|
+
const response = await request(app)
|
|
31
|
+
.get("/config")
|
|
32
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
33
|
+
expect(response.status).toBe(200);
|
|
34
|
+
expect(response.body).toHaveProperty("defaultEnvironment");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe("Invalid Authentication", () => {
|
|
38
|
+
it("should reject requests without auth header", async () => {
|
|
39
|
+
const response = await request(app).get("/config");
|
|
40
|
+
expect(response.status).toBe(401);
|
|
41
|
+
expect(response.body.error).toBe("Unauthorized");
|
|
42
|
+
});
|
|
43
|
+
it("should reject requests with invalid token", async () => {
|
|
44
|
+
const response = await request(app)
|
|
45
|
+
.get("/config")
|
|
46
|
+
.set("x-mcp-proxy-auth", "Bearer invalid-token-here");
|
|
47
|
+
expect(response.status).toBe(401);
|
|
48
|
+
expect(response.body.error).toBe("Unauthorized");
|
|
49
|
+
});
|
|
50
|
+
it("should reject requests without Bearer prefix", async () => {
|
|
51
|
+
const response = await request(app)
|
|
52
|
+
.get("/config")
|
|
53
|
+
.set("x-mcp-proxy-auth", sessionToken);
|
|
54
|
+
expect(response.status).toBe(401);
|
|
55
|
+
expect(response.body.error).toBe("Unauthorized");
|
|
56
|
+
});
|
|
57
|
+
it("should reject requests with wrong auth scheme", async () => {
|
|
58
|
+
const response = await request(app)
|
|
59
|
+
.get("/config")
|
|
60
|
+
.set("x-mcp-proxy-auth", `Basic ${sessionToken}`);
|
|
61
|
+
expect(response.status).toBe(401);
|
|
62
|
+
expect(response.body.error).toBe("Unauthorized");
|
|
63
|
+
});
|
|
64
|
+
it("should reject requests with empty Bearer token", async () => {
|
|
65
|
+
const response = await request(app)
|
|
66
|
+
.get("/config")
|
|
67
|
+
.set("x-mcp-proxy-auth", "Bearer ");
|
|
68
|
+
expect(response.status).toBe(401);
|
|
69
|
+
expect(response.body.error).toBe("Unauthorized");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("Auth Header Variations", () => {
|
|
73
|
+
it("should handle array auth header (use first)", async () => {
|
|
74
|
+
// Express may receive array headers - we use the first one
|
|
75
|
+
const response = await request(app)
|
|
76
|
+
.get("/config")
|
|
77
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`)
|
|
78
|
+
.set("x-mcp-proxy-auth", "Bearer invalid"); // supertest merges, but real Express uses first
|
|
79
|
+
// In supertest, subsequent sets may override or merge depending on version
|
|
80
|
+
// The middleware uses first array element, so this should work
|
|
81
|
+
expect([200, 401]).toContain(response.status);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("Origin Validation Middleware", () => {
|
|
86
|
+
describe("Valid Origins", () => {
|
|
87
|
+
it("should accept requests without Origin header", async () => {
|
|
88
|
+
const response = await request(app)
|
|
89
|
+
.get("/health")
|
|
90
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
91
|
+
expect(response.status).toBe(200);
|
|
92
|
+
});
|
|
93
|
+
it("should accept requests from localhost default origin", async () => {
|
|
94
|
+
const response = await request(app)
|
|
95
|
+
.get("/health")
|
|
96
|
+
.set("Origin", "http://localhost:6274")
|
|
97
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
98
|
+
expect(response.status).toBe(200);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("Invalid Origins", () => {
|
|
102
|
+
it("should reject requests from invalid origins", async () => {
|
|
103
|
+
const response = await request(app)
|
|
104
|
+
.get("/config")
|
|
105
|
+
.set("Origin", "http://evil.com")
|
|
106
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
107
|
+
expect(response.status).toBe(403);
|
|
108
|
+
expect(response.body.error).toContain("Forbidden");
|
|
109
|
+
});
|
|
110
|
+
it("should reject requests from non-localhost origins", async () => {
|
|
111
|
+
const response = await request(app)
|
|
112
|
+
.get("/config")
|
|
113
|
+
.set("Origin", "http://example.com:6274")
|
|
114
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
115
|
+
expect(response.status).toBe(403);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe("Security Headers", () => {
|
|
120
|
+
it("should set Content-Security-Policy header", async () => {
|
|
121
|
+
const response = await request(app)
|
|
122
|
+
.get("/health")
|
|
123
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
124
|
+
expect(response.headers["content-security-policy"]).toBeDefined();
|
|
125
|
+
expect(response.headers["content-security-policy"]).toContain("default-src");
|
|
126
|
+
});
|
|
127
|
+
it("should set X-Content-Type-Options: nosniff", async () => {
|
|
128
|
+
const response = await request(app)
|
|
129
|
+
.get("/health")
|
|
130
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
131
|
+
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
|
132
|
+
});
|
|
133
|
+
it("should set X-Frame-Options: DENY", async () => {
|
|
134
|
+
const response = await request(app)
|
|
135
|
+
.get("/health")
|
|
136
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
137
|
+
expect(response.headers["x-frame-options"]).toBe("DENY");
|
|
138
|
+
});
|
|
139
|
+
it("should expose mcp-session-id header via CORS", async () => {
|
|
140
|
+
const response = await request(app)
|
|
141
|
+
.get("/health")
|
|
142
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
143
|
+
expect(response.headers["access-control-expose-headers"]).toContain("mcp-session-id");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe("CORS Configuration", () => {
|
|
147
|
+
it("should allow CORS requests", async () => {
|
|
148
|
+
const response = await request(app)
|
|
149
|
+
.options("/health")
|
|
150
|
+
.set("Origin", "http://localhost:6274")
|
|
151
|
+
.set("Access-Control-Request-Method", "GET");
|
|
152
|
+
// CORS preflight should succeed
|
|
153
|
+
expect(response.status).toBeLessThan(400);
|
|
154
|
+
});
|
|
155
|
+
it("should include Access-Control-Allow-Origin header", async () => {
|
|
156
|
+
const response = await request(app)
|
|
157
|
+
.get("/health")
|
|
158
|
+
.set("Origin", "http://localhost:6274")
|
|
159
|
+
.set("x-mcp-proxy-auth", `Bearer ${sessionToken}`);
|
|
160
|
+
expect(response.headers["access-control-allow-origin"]).toBeDefined();
|
|
161
|
+
});
|
|
162
|
+
});
|
package/build/helpers.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Helper Functions
|
|
3
|
+
*
|
|
4
|
+
* Pure functions extracted from index.ts for testability.
|
|
5
|
+
*/
|
|
6
|
+
import { SseError } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
7
|
+
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
/**
|
|
9
|
+
* Helper function to detect 401 Unauthorized errors from various transport types.
|
|
10
|
+
* StreamableHTTPClientTransport throws a generic Error with "HTTP 401" in the message
|
|
11
|
+
* when there's no authProvider configured, while SSEClientTransport throws SseError.
|
|
12
|
+
*/
|
|
13
|
+
export const is401Error = (error) => {
|
|
14
|
+
if (error instanceof SseError && error.code === 401)
|
|
15
|
+
return true;
|
|
16
|
+
if (error instanceof StreamableHTTPError && error.code === 401)
|
|
17
|
+
return true;
|
|
18
|
+
if (error instanceof Error &&
|
|
19
|
+
(error.message.includes("HTTP 401") || error.message.includes("(401)")))
|
|
20
|
+
return true;
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Get HTTP headers from an Express request that should be forwarded to the MCP server.
|
|
25
|
+
* Filters to include mcp-* headers, authorization, and last-event-id.
|
|
26
|
+
* Excludes the proxy's own auth header (x-mcp-proxy-auth) and session id (mcp-session-id).
|
|
27
|
+
*/
|
|
28
|
+
export const getHttpHeaders = (req) => {
|
|
29
|
+
const headers = {};
|
|
30
|
+
// Iterate over all headers in the request
|
|
31
|
+
for (const key in req.headers) {
|
|
32
|
+
const lowerKey = key.toLowerCase();
|
|
33
|
+
// Check if the header is one we want to forward
|
|
34
|
+
if (lowerKey.startsWith("mcp-") ||
|
|
35
|
+
lowerKey === "authorization" ||
|
|
36
|
+
lowerKey === "last-event-id") {
|
|
37
|
+
// Exclude the proxy's own authentication header and the Client <-> Proxy session ID header
|
|
38
|
+
if (lowerKey !== "x-mcp-proxy-auth" && lowerKey !== "mcp-session-id") {
|
|
39
|
+
const value = req.headers[key];
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
// If the value is a string, use it directly
|
|
42
|
+
headers[key] = value;
|
|
43
|
+
}
|
|
44
|
+
else if (Array.isArray(value)) {
|
|
45
|
+
// If the value is an array, use the last element
|
|
46
|
+
const lastValue = value.at(-1);
|
|
47
|
+
if (lastValue !== undefined) {
|
|
48
|
+
headers[key] = lastValue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// If value is undefined, it's skipped, which is correct.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Handle the custom auth header separately. We expect `x-custom-auth-header`
|
|
56
|
+
// to be a string containing the name of the actual authentication header.
|
|
57
|
+
const customAuthHeaderName = req.headers["x-custom-auth-header"];
|
|
58
|
+
if (typeof customAuthHeaderName === "string") {
|
|
59
|
+
const lowerCaseHeaderName = customAuthHeaderName.toLowerCase();
|
|
60
|
+
const value = req.headers[lowerCaseHeaderName];
|
|
61
|
+
if (typeof value === "string") {
|
|
62
|
+
headers[customAuthHeaderName] = value;
|
|
63
|
+
}
|
|
64
|
+
else if (Array.isArray(value)) {
|
|
65
|
+
// If the actual auth header was sent multiple times, use the last value.
|
|
66
|
+
const lastValue = value.at(-1);
|
|
67
|
+
if (lastValue !== undefined) {
|
|
68
|
+
headers[customAuthHeaderName] = lastValue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Handle multiple custom headers (new approach)
|
|
73
|
+
if (req.headers["x-custom-auth-headers"] !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
const customHeaderNames = JSON.parse(req.headers["x-custom-auth-headers"]);
|
|
76
|
+
if (Array.isArray(customHeaderNames)) {
|
|
77
|
+
customHeaderNames.forEach((headerName) => {
|
|
78
|
+
const lowerCaseHeaderName = headerName.toLowerCase();
|
|
79
|
+
if (req.headers[lowerCaseHeaderName] !== undefined) {
|
|
80
|
+
const value = req.headers[lowerCaseHeaderName];
|
|
81
|
+
headers[headerName] = Array.isArray(value)
|
|
82
|
+
? value[value.length - 1]
|
|
83
|
+
: value;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.warn("Failed to parse x-custom-auth-headers:", error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return headers;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Updates a headers object in-place, preserving the original Accept header.
|
|
96
|
+
* This is necessary to ensure that transports holding a reference to the headers
|
|
97
|
+
* object see the updates.
|
|
98
|
+
* @param currentHeaders The headers object to update.
|
|
99
|
+
* @param newHeaders The new headers to apply.
|
|
100
|
+
*/
|
|
101
|
+
export const updateHeadersInPlace = (currentHeaders, newHeaders) => {
|
|
102
|
+
// Preserve the Accept header, which is set at transport creation and
|
|
103
|
+
// is not present in subsequent client requests.
|
|
104
|
+
const accept = currentHeaders["Accept"];
|
|
105
|
+
// Clear the old headers and apply the new ones.
|
|
106
|
+
Object.keys(currentHeaders).forEach((key) => delete currentHeaders[key]);
|
|
107
|
+
Object.assign(currentHeaders, newHeaders);
|
|
108
|
+
// Restore the Accept header.
|
|
109
|
+
if (accept) {
|
|
110
|
+
currentHeaders["Accept"] = accept;
|
|
111
|
+
}
|
|
112
|
+
};
|
package/build/index.js
CHANGED
|
@@ -10,12 +10,13 @@ const fetch = nodeFetch;
|
|
|
10
10
|
const Headers = NodeHeaders;
|
|
11
11
|
import { SSEClientTransport, SseError, } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
12
12
|
import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
13
|
-
import { StreamableHTTPClientTransport,
|
|
13
|
+
import { StreamableHTTPClientTransport, } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
14
14
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
15
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
16
16
|
import express from "express";
|
|
17
17
|
import { findActualExecutable } from "spawn-rx";
|
|
18
18
|
import mcpProxy from "./mcpProxy.js";
|
|
19
|
+
import { is401Error, getHttpHeaders, updateHeadersInPlace } from "./helpers.js";
|
|
19
20
|
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
|
20
21
|
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
|
21
22
|
const defaultEnvironment = {
|
|
@@ -32,107 +33,6 @@ const { values } = parseArgs({
|
|
|
32
33
|
"server-url": { type: "string", default: "" },
|
|
33
34
|
},
|
|
34
35
|
});
|
|
35
|
-
/**
|
|
36
|
-
* Helper function to detect 401 Unauthorized errors from various transport types.
|
|
37
|
-
* StreamableHTTPClientTransport throws a generic Error with "HTTP 401" in the message
|
|
38
|
-
* when there's no authProvider configured, while SSEClientTransport throws SseError.
|
|
39
|
-
*/
|
|
40
|
-
const is401Error = (error) => {
|
|
41
|
-
if (error instanceof SseError && error.code === 401)
|
|
42
|
-
return true;
|
|
43
|
-
if (error instanceof StreamableHTTPError && error.code === 401)
|
|
44
|
-
return true;
|
|
45
|
-
if (error instanceof Error &&
|
|
46
|
-
(error.message.includes("HTTP 401") || error.message.includes("(401)")))
|
|
47
|
-
return true;
|
|
48
|
-
return false;
|
|
49
|
-
};
|
|
50
|
-
// Function to get HTTP headers.
|
|
51
|
-
const getHttpHeaders = (req) => {
|
|
52
|
-
const headers = {};
|
|
53
|
-
// Iterate over all headers in the request
|
|
54
|
-
for (const key in req.headers) {
|
|
55
|
-
const lowerKey = key.toLowerCase();
|
|
56
|
-
// Check if the header is one we want to forward
|
|
57
|
-
if (lowerKey.startsWith("mcp-") ||
|
|
58
|
-
lowerKey === "authorization" ||
|
|
59
|
-
lowerKey === "last-event-id") {
|
|
60
|
-
// Exclude the proxy's own authentication header and the Client <-> Proxy session ID header
|
|
61
|
-
if (lowerKey !== "x-mcp-proxy-auth" && lowerKey !== "mcp-session-id") {
|
|
62
|
-
const value = req.headers[key];
|
|
63
|
-
if (typeof value === "string") {
|
|
64
|
-
// If the value is a string, use it directly
|
|
65
|
-
headers[key] = value;
|
|
66
|
-
}
|
|
67
|
-
else if (Array.isArray(value)) {
|
|
68
|
-
// If the value is an array, use the last element
|
|
69
|
-
const lastValue = value.at(-1);
|
|
70
|
-
if (lastValue !== undefined) {
|
|
71
|
-
headers[key] = lastValue;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
// If value is undefined, it's skipped, which is correct.
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
// Handle the custom auth header separately. We expect `x-custom-auth-header`
|
|
79
|
-
// to be a string containing the name of the actual authentication header.
|
|
80
|
-
const customAuthHeaderName = req.headers["x-custom-auth-header"];
|
|
81
|
-
if (typeof customAuthHeaderName === "string") {
|
|
82
|
-
const lowerCaseHeaderName = customAuthHeaderName.toLowerCase();
|
|
83
|
-
const value = req.headers[lowerCaseHeaderName];
|
|
84
|
-
if (typeof value === "string") {
|
|
85
|
-
headers[customAuthHeaderName] = value;
|
|
86
|
-
}
|
|
87
|
-
else if (Array.isArray(value)) {
|
|
88
|
-
// If the actual auth header was sent multiple times, use the last value.
|
|
89
|
-
const lastValue = value.at(-1);
|
|
90
|
-
if (lastValue !== undefined) {
|
|
91
|
-
headers[customAuthHeaderName] = lastValue;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
// Handle multiple custom headers (new approach)
|
|
96
|
-
if (req.headers["x-custom-auth-headers"] !== undefined) {
|
|
97
|
-
try {
|
|
98
|
-
const customHeaderNames = JSON.parse(req.headers["x-custom-auth-headers"]);
|
|
99
|
-
if (Array.isArray(customHeaderNames)) {
|
|
100
|
-
customHeaderNames.forEach((headerName) => {
|
|
101
|
-
const lowerCaseHeaderName = headerName.toLowerCase();
|
|
102
|
-
if (req.headers[lowerCaseHeaderName] !== undefined) {
|
|
103
|
-
const value = req.headers[lowerCaseHeaderName];
|
|
104
|
-
headers[headerName] = Array.isArray(value)
|
|
105
|
-
? value[value.length - 1]
|
|
106
|
-
: value;
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
catch (error) {
|
|
112
|
-
console.warn("Failed to parse x-custom-auth-headers:", error);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return headers;
|
|
116
|
-
};
|
|
117
|
-
/**
|
|
118
|
-
* Updates a headers object in-place, preserving the original Accept header.
|
|
119
|
-
* This is necessary to ensure that transports holding a reference to the headers
|
|
120
|
-
* object see the updates.
|
|
121
|
-
* @param currentHeaders The headers object to update.
|
|
122
|
-
* @param newHeaders The new headers to apply.
|
|
123
|
-
*/
|
|
124
|
-
const updateHeadersInPlace = (currentHeaders, newHeaders) => {
|
|
125
|
-
// Preserve the Accept header, which is set at transport creation and
|
|
126
|
-
// is not present in subsequent client requests.
|
|
127
|
-
const accept = currentHeaders["Accept"];
|
|
128
|
-
// Clear the old headers and apply the new ones.
|
|
129
|
-
Object.keys(currentHeaders).forEach((key) => delete currentHeaders[key]);
|
|
130
|
-
Object.assign(currentHeaders, newHeaders);
|
|
131
|
-
// Restore the Accept header.
|
|
132
|
-
if (accept) {
|
|
133
|
-
currentHeaders["Accept"] = accept;
|
|
134
|
-
}
|
|
135
|
-
};
|
|
136
36
|
const app = express();
|
|
137
37
|
app.use(cors());
|
|
138
38
|
// [SECURITY-ENHANCEMENT] - triepod-ai fork: Rate limiting to prevent DoS attacks
|
|
@@ -681,8 +581,10 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
|
|
|
681
581
|
});
|
|
682
582
|
const PORT = parseInt(process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT, 10);
|
|
683
583
|
const HOST = process.env.HOST || "localhost";
|
|
684
|
-
|
|
685
|
-
|
|
584
|
+
// Don't start server in test mode - allows supertest to manage the server
|
|
585
|
+
const isTestMode = process.env.NODE_ENV === "test";
|
|
586
|
+
const server = isTestMode ? null : app.listen(PORT, HOST);
|
|
587
|
+
server?.on("listening", () => {
|
|
686
588
|
console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`);
|
|
687
589
|
if (!authDisabled) {
|
|
688
590
|
console.log(`🔑 Session token: ${sessionToken}\n ` +
|
|
@@ -692,7 +594,7 @@ server.on("listening", () => {
|
|
|
692
594
|
console.log(`⚠️ WARNING: Authentication is disabled. This is not recommended.`);
|
|
693
595
|
}
|
|
694
596
|
});
|
|
695
|
-
server
|
|
597
|
+
server?.on("error", (err) => {
|
|
696
598
|
if (err.message.includes(`EADDRINUSE`)) {
|
|
697
599
|
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `);
|
|
698
600
|
}
|
|
@@ -701,3 +603,5 @@ server.on("error", (err) => {
|
|
|
701
603
|
}
|
|
702
604
|
process.exit(1);
|
|
703
605
|
});
|
|
606
|
+
// Export app and sessionToken for testing with supertest
|
|
607
|
+
export { app, sessionToken };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bryan-thompson/inspector-assessment-server",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.4",
|
|
4
4
|
"description": "Server-side application for the Enhanced MCP Inspector with assessment capabilities",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bryan Thompson <bryan@triepod.ai>",
|
|
@@ -27,13 +27,19 @@
|
|
|
27
27
|
"build": "tsc",
|
|
28
28
|
"start": "node build/index.js",
|
|
29
29
|
"dev": "tsx watch --clear-screen=false src/index.ts",
|
|
30
|
-
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
|
|
30
|
+
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL",
|
|
31
|
+
"test": "node --experimental-vm-modules ../node_modules/.bin/jest --config jest.config.cjs"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@types/cors": "^2.8.19",
|
|
34
35
|
"@types/express": "^4.17.23",
|
|
36
|
+
"@types/jest": "^29.5.14",
|
|
35
37
|
"@types/shell-quote": "^1.7.5",
|
|
38
|
+
"@types/supertest": "^6.0.2",
|
|
36
39
|
"@types/ws": "^8.5.12",
|
|
40
|
+
"jest": "^29.7.0",
|
|
41
|
+
"supertest": "^7.0.0",
|
|
42
|
+
"ts-jest": "^29.2.0",
|
|
37
43
|
"tsx": "^4.19.0",
|
|
38
44
|
"typescript": "^5.6.2"
|
|
39
45
|
},
|