mcp-proxy 5.10.0 → 5.11.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/dist/bin/mcp-proxy.js +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +1 -1
- package/dist/{stdio-DF5lH8jj.js → stdio-DLSsHME0.js} +77 -21
- package/dist/stdio-DLSsHME0.js.map +1 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/InMemoryEventStore.test.ts +72 -0
- package/src/InMemoryEventStore.ts +19 -2
- package/src/authentication.test.ts +145 -7
- package/src/authentication.ts +51 -5
- package/src/proxyServer.ts +8 -6
- package/src/startHTTPServer.test.ts +144 -0
- package/src/startHTTPServer.ts +106 -22
- package/dist/stdio-DF5lH8jj.js.map +0 -1
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -157,4 +157,76 @@ describe("InMemoryEventStore", () => {
|
|
|
157
157
|
);
|
|
158
158
|
expect(emptyResult).toBe("");
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
it("keeps deterministic ordering even when events share the same timestamp", async () => {
|
|
162
|
+
const store = new InMemoryEventStore();
|
|
163
|
+
const streamId = "deterministic-stream";
|
|
164
|
+
|
|
165
|
+
const messages: JSONRPCMessage[] = [
|
|
166
|
+
{ id: 1, jsonrpc: "2.0", method: "step/one" },
|
|
167
|
+
{ id: 2, jsonrpc: "2.0", method: "step/two" },
|
|
168
|
+
{ id: 3, jsonrpc: "2.0", method: "step/three" },
|
|
169
|
+
{ id: 4, jsonrpc: "2.0", method: "step/four" },
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const fixedTimestamp = 1_730_000_000_000;
|
|
173
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(fixedTimestamp);
|
|
174
|
+
|
|
175
|
+
const eventIds: string[] = [];
|
|
176
|
+
try {
|
|
177
|
+
for (const message of messages) {
|
|
178
|
+
eventIds.push(await store.storeEvent(streamId, message));
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
nowSpy.mockRestore();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Ensure IDs already arrive sorted since we stored sequentially
|
|
185
|
+
expect(eventIds).toEqual([...eventIds].sort());
|
|
186
|
+
|
|
187
|
+
const parts = eventIds.map((eventId) => eventId.split("_"));
|
|
188
|
+
|
|
189
|
+
const timestampParts = parts.map(([, timestamp]) => timestamp);
|
|
190
|
+
expect(timestampParts).toEqual(
|
|
191
|
+
Array(messages.length).fill(fixedTimestamp.toString())
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const counterSuffixes = parts.map(([, , counter]) => counter);
|
|
195
|
+
expect(counterSuffixes).toEqual(
|
|
196
|
+
messages.map((_, index) => (index).toString(36).padStart(4, "0"))
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Random parts should be 3 base36 characters each (due to substring(2, 5))
|
|
200
|
+
const randomParts = parts.map(([, , , random]) => random);
|
|
201
|
+
for (const random of randomParts) {
|
|
202
|
+
expect(random).toMatch(/^[0-9a-z]{3}$/);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Replay after the first event and ensure the remainder flow in order
|
|
206
|
+
const replayedMessages: JSONRPCMessage[] = [];
|
|
207
|
+
const returnedStreamId = await store.replayEventsAfter(eventIds[0], {
|
|
208
|
+
send: async (_eventId: string, message: JSONRPCMessage) => {
|
|
209
|
+
replayedMessages.push(message);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(returnedStreamId).toBe(streamId);
|
|
214
|
+
expect(replayedMessages).toEqual(messages.slice(1));
|
|
215
|
+
|
|
216
|
+
// Now allow timestamp to advance to ensure counter resets
|
|
217
|
+
const nextTimestamp = fixedTimestamp + 1;
|
|
218
|
+
const secondSpy = vi.spyOn(Date, "now").mockReturnValue(nextTimestamp);
|
|
219
|
+
try {
|
|
220
|
+
const nextId = await store.storeEvent(streamId, {
|
|
221
|
+
id: 5,
|
|
222
|
+
jsonrpc: "2.0",
|
|
223
|
+
method: "step/five",
|
|
224
|
+
});
|
|
225
|
+
const [, , counter, random] = nextId.split("_");
|
|
226
|
+
expect(counter).toBe("0000");
|
|
227
|
+
expect(random).toMatch(/^[0-9a-z]{3}$/);
|
|
228
|
+
} finally {
|
|
229
|
+
secondSpy.mockRestore();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
160
232
|
});
|
|
@@ -14,6 +14,8 @@ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
|
|
14
14
|
export class InMemoryEventStore implements EventStore {
|
|
15
15
|
private events: Map<string, { message: JSONRPCMessage; streamId: string }> =
|
|
16
16
|
new Map();
|
|
17
|
+
private lastTimestamp = 0;
|
|
18
|
+
private lastTimestampCounter = 0;
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Replays events that occurred after a specific event ID
|
|
@@ -79,10 +81,25 @@ export class InMemoryEventStore implements EventStore {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/**
|
|
82
|
-
* Generates a unique event ID
|
|
84
|
+
* Generates a monotonic unique event ID in
|
|
85
|
+
* `${streamId}_${timestamp}_${counter}_${random}` format.
|
|
83
86
|
*/
|
|
84
87
|
private generateEventId(streamId: string): string {
|
|
85
|
-
|
|
88
|
+
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
|
|
91
|
+
if (now === this.lastTimestamp) {
|
|
92
|
+
this.lastTimestampCounter++;
|
|
93
|
+
} else {
|
|
94
|
+
this.lastTimestampCounter = 0;
|
|
95
|
+
this.lastTimestamp = now;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const timestamp = now.toString();
|
|
99
|
+
const counter = this.lastTimestampCounter.toString(36).padStart(4, "0");
|
|
100
|
+
const random = Math.random().toString(36).substring(2, 5);
|
|
101
|
+
|
|
102
|
+
return `${streamId}_${timestamp}_${counter}_${random}`;
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
/**
|
|
@@ -132,7 +132,7 @@ describe("AuthenticationMiddleware", () => {
|
|
|
132
132
|
const response = middleware.getUnauthorizedResponse();
|
|
133
133
|
|
|
134
134
|
expect(response.headers["WWW-Authenticate"]).toBe(
|
|
135
|
-
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
|
|
135
|
+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="Unauthorized: Invalid or missing API key"',
|
|
136
136
|
);
|
|
137
137
|
});
|
|
138
138
|
|
|
@@ -148,21 +148,24 @@ describe("AuthenticationMiddleware", () => {
|
|
|
148
148
|
const response = middleware.getUnauthorizedResponse();
|
|
149
149
|
|
|
150
150
|
expect(response.headers["WWW-Authenticate"]).toBe(
|
|
151
|
-
'Bearer resource_metadata="https://example.com//.well-known/oauth-protected-resource"',
|
|
151
|
+
'Bearer resource_metadata="https://example.com//.well-known/oauth-protected-resource", error="invalid_token", error_description="Unauthorized: Invalid or missing API key"',
|
|
152
152
|
);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
it("should
|
|
155
|
+
it("should include minimal WWW-Authenticate header when OAuth config is empty object", () => {
|
|
156
156
|
const middleware = new AuthenticationMiddleware({
|
|
157
157
|
apiKey: "test",
|
|
158
158
|
oauth: {},
|
|
159
159
|
});
|
|
160
160
|
const response = middleware.getUnauthorizedResponse();
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
// Even with empty oauth object, default error and error_description are added
|
|
163
|
+
expect(response.headers["WWW-Authenticate"]).toBe(
|
|
164
|
+
'Bearer error="invalid_token", error_description="Unauthorized: Invalid or missing API key"',
|
|
165
|
+
);
|
|
163
166
|
});
|
|
164
167
|
|
|
165
|
-
it("should
|
|
168
|
+
it("should include minimal WWW-Authenticate header when protectedResource is empty", () => {
|
|
166
169
|
const middleware = new AuthenticationMiddleware({
|
|
167
170
|
apiKey: "test",
|
|
168
171
|
oauth: {
|
|
@@ -171,7 +174,10 @@ describe("AuthenticationMiddleware", () => {
|
|
|
171
174
|
});
|
|
172
175
|
const response = middleware.getUnauthorizedResponse();
|
|
173
176
|
|
|
174
|
-
|
|
177
|
+
// Even without resource_metadata, default error and error_description are added
|
|
178
|
+
expect(response.headers["WWW-Authenticate"]).toBe(
|
|
179
|
+
'Bearer error="invalid_token", error_description="Unauthorized: Invalid or missing API key"',
|
|
180
|
+
);
|
|
175
181
|
});
|
|
176
182
|
|
|
177
183
|
it("should include WWW-Authenticate header with OAuth config but no apiKey", () => {
|
|
@@ -185,8 +191,140 @@ describe("AuthenticationMiddleware", () => {
|
|
|
185
191
|
const response = middleware.getUnauthorizedResponse();
|
|
186
192
|
|
|
187
193
|
expect(response.headers["WWW-Authenticate"]).toBe(
|
|
188
|
-
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
|
|
194
|
+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="Unauthorized: Invalid or missing API key"',
|
|
189
195
|
);
|
|
190
196
|
});
|
|
197
|
+
|
|
198
|
+
it("should include realm in WWW-Authenticate header when configured", () => {
|
|
199
|
+
const middleware = new AuthenticationMiddleware({
|
|
200
|
+
apiKey: "test",
|
|
201
|
+
oauth: {
|
|
202
|
+
protectedResource: {
|
|
203
|
+
resource: "https://example.com",
|
|
204
|
+
},
|
|
205
|
+
realm: "example-realm",
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
const response = middleware.getUnauthorizedResponse();
|
|
209
|
+
|
|
210
|
+
expect(response.headers["WWW-Authenticate"]).toContain('realm="example-realm"');
|
|
211
|
+
expect(response.headers["WWW-Authenticate"]).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should include custom error in WWW-Authenticate header via options", () => {
|
|
215
|
+
const middleware = new AuthenticationMiddleware({
|
|
216
|
+
apiKey: "test",
|
|
217
|
+
oauth: {
|
|
218
|
+
protectedResource: {
|
|
219
|
+
resource: "https://example.com",
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const response = middleware.getUnauthorizedResponse({
|
|
224
|
+
error: "insufficient_scope",
|
|
225
|
+
error_description: "The request requires higher privileges",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(response.headers["WWW-Authenticate"]).toContain('error="insufficient_scope"');
|
|
229
|
+
expect(response.headers["WWW-Authenticate"]).toContain('error_description="The request requires higher privileges"');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("should include scope in WWW-Authenticate header", () => {
|
|
233
|
+
const middleware = new AuthenticationMiddleware({
|
|
234
|
+
apiKey: "test",
|
|
235
|
+
oauth: {
|
|
236
|
+
protectedResource: {
|
|
237
|
+
resource: "https://example.com",
|
|
238
|
+
},
|
|
239
|
+
scope: "read write",
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const response = middleware.getUnauthorizedResponse();
|
|
243
|
+
|
|
244
|
+
expect(response.headers["WWW-Authenticate"]).toContain('scope="read write"');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should override config error with options error", () => {
|
|
248
|
+
const middleware = new AuthenticationMiddleware({
|
|
249
|
+
apiKey: "test",
|
|
250
|
+
oauth: {
|
|
251
|
+
error: "invalid_request",
|
|
252
|
+
error_description: "Config error description",
|
|
253
|
+
protectedResource: {
|
|
254
|
+
resource: "https://example.com",
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const response = middleware.getUnauthorizedResponse({
|
|
259
|
+
error: "invalid_token",
|
|
260
|
+
error_description: "Options error description",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(response.headers["WWW-Authenticate"]).toContain('error="invalid_token"');
|
|
264
|
+
expect(response.headers["WWW-Authenticate"]).toContain('error_description="Options error description"');
|
|
265
|
+
expect(response.headers["WWW-Authenticate"]).not.toContain("invalid_request");
|
|
266
|
+
expect(response.headers["WWW-Authenticate"]).not.toContain("Config error description");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should include error_uri in WWW-Authenticate header", () => {
|
|
270
|
+
const middleware = new AuthenticationMiddleware({
|
|
271
|
+
apiKey: "test",
|
|
272
|
+
oauth: {
|
|
273
|
+
error_uri: "https://example.com/errors/auth",
|
|
274
|
+
protectedResource: {
|
|
275
|
+
resource: "https://example.com",
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const response = middleware.getUnauthorizedResponse();
|
|
280
|
+
|
|
281
|
+
expect(response.headers["WWW-Authenticate"]).toContain('error_uri="https://example.com/errors/auth"');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("should properly escape quotes in error_description", () => {
|
|
285
|
+
const middleware = new AuthenticationMiddleware({
|
|
286
|
+
apiKey: "test",
|
|
287
|
+
oauth: {
|
|
288
|
+
protectedResource: {
|
|
289
|
+
resource: "https://example.com",
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const response = middleware.getUnauthorizedResponse({
|
|
294
|
+
error_description: 'Token "abc123" is invalid',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(response.headers["WWW-Authenticate"]).toContain('error_description="Token \\"abc123\\" is invalid"');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should include all parameters in correct order", () => {
|
|
301
|
+
const middleware = new AuthenticationMiddleware({
|
|
302
|
+
apiKey: "test",
|
|
303
|
+
oauth: {
|
|
304
|
+
error: "invalid_token",
|
|
305
|
+
error_description: "Token expired",
|
|
306
|
+
error_uri: "https://example.com/errors",
|
|
307
|
+
protectedResource: {
|
|
308
|
+
resource: "https://example.com",
|
|
309
|
+
},
|
|
310
|
+
realm: "my-realm",
|
|
311
|
+
scope: "read write",
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const response = middleware.getUnauthorizedResponse();
|
|
315
|
+
|
|
316
|
+
const header = response.headers["WWW-Authenticate"];
|
|
317
|
+
expect(header).toContain('realm="my-realm"');
|
|
318
|
+
expect(header).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"');
|
|
319
|
+
expect(header).toContain('error="invalid_token"');
|
|
320
|
+
expect(header).toContain('error_description="Token expired"');
|
|
321
|
+
expect(header).toContain('error_uri="https://example.com/errors"');
|
|
322
|
+
expect(header).toContain('scope="read write"');
|
|
323
|
+
|
|
324
|
+
// Check order: realm, resource_metadata, error, error_description, error_uri, scope
|
|
325
|
+
expect(header.indexOf('realm=')).toBeLessThan(header.indexOf('resource_metadata='));
|
|
326
|
+
expect(header.indexOf('resource_metadata=')).toBeLessThan(header.indexOf('error='));
|
|
327
|
+
expect(header.indexOf('error=')).toBeLessThan(header.indexOf('error_description='));
|
|
328
|
+
});
|
|
191
329
|
});
|
|
192
330
|
});
|
package/src/authentication.ts
CHANGED
|
@@ -3,30 +3,76 @@ import type { IncomingMessage } from "http";
|
|
|
3
3
|
export interface AuthConfig {
|
|
4
4
|
apiKey?: string;
|
|
5
5
|
oauth?: {
|
|
6
|
+
error?: string;
|
|
7
|
+
error_description?: string;
|
|
8
|
+
error_uri?: string;
|
|
6
9
|
protectedResource?: {
|
|
7
10
|
resource?: string;
|
|
8
11
|
};
|
|
12
|
+
realm?: string;
|
|
13
|
+
scope?: string;
|
|
9
14
|
};
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
export class AuthenticationMiddleware {
|
|
13
18
|
constructor(private config: AuthConfig = {}) {}
|
|
14
19
|
|
|
15
|
-
getUnauthorizedResponse(
|
|
20
|
+
getUnauthorizedResponse(options?: {
|
|
21
|
+
error?: string;
|
|
22
|
+
error_description?: string;
|
|
23
|
+
error_uri?: string;
|
|
24
|
+
scope?: string;
|
|
25
|
+
}): { body: string; headers: Record<string, string> } {
|
|
16
26
|
const headers: Record<string, string> = {
|
|
17
27
|
"Content-Type": "application/json",
|
|
18
28
|
};
|
|
19
29
|
|
|
20
|
-
//
|
|
21
|
-
if (this.config.oauth
|
|
22
|
-
|
|
30
|
+
// Build WWW-Authenticate header if OAuth config is available
|
|
31
|
+
if (this.config.oauth) {
|
|
32
|
+
const params: string[] = [];
|
|
33
|
+
|
|
34
|
+
// Add realm if configured
|
|
35
|
+
if (this.config.oauth.realm) {
|
|
36
|
+
params.push(`realm="${this.config.oauth.realm}"`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Add resource_metadata if configured
|
|
40
|
+
if (this.config.oauth.protectedResource?.resource) {
|
|
41
|
+
params.push(`resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Add error from options or config (options takes precedence)
|
|
45
|
+
const error = options?.error || this.config.oauth.error || "invalid_token";
|
|
46
|
+
params.push(`error="${error}"`);
|
|
47
|
+
|
|
48
|
+
// Add error_description from options or config (options takes precedence)
|
|
49
|
+
const error_description = options?.error_description || this.config.oauth.error_description || "Unauthorized: Invalid or missing API key";
|
|
50
|
+
// Escape quotes in error description
|
|
51
|
+
const escaped = error_description.replace(/"/g, '\\"');
|
|
52
|
+
params.push(`error_description="${escaped}"`);
|
|
53
|
+
|
|
54
|
+
// Add error_uri from options or config (options takes precedence)
|
|
55
|
+
const error_uri = options?.error_uri || this.config.oauth.error_uri;
|
|
56
|
+
if (error_uri) {
|
|
57
|
+
params.push(`error_uri="${error_uri}"`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Add scope from options or config (options takes precedence)
|
|
61
|
+
const scope = options?.scope || this.config.oauth.scope;
|
|
62
|
+
if (scope) {
|
|
63
|
+
params.push(`scope="${scope}"`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (params.length > 0) {
|
|
67
|
+
headers["WWW-Authenticate"] = `Bearer ${params.join(", ")}`;
|
|
68
|
+
}
|
|
23
69
|
}
|
|
24
70
|
|
|
25
71
|
return {
|
|
26
72
|
body: JSON.stringify({
|
|
27
73
|
error: {
|
|
28
74
|
code: 401,
|
|
29
|
-
message: "Unauthorized: Invalid or missing API key",
|
|
75
|
+
message: options?.error_description || "Unauthorized: Invalid or missing API key",
|
|
30
76
|
},
|
|
31
77
|
id: null,
|
|
32
78
|
jsonrpc: "2.0",
|
package/src/proxyServer.ts
CHANGED
|
@@ -124,10 +124,12 @@ export const proxyServer = async ({
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
127
|
+
if (serverCapabilities?.completions) {
|
|
128
|
+
server.setRequestHandler(CompleteRequestSchema, async (args) => {
|
|
129
|
+
return client.complete(
|
|
130
|
+
args.params,
|
|
131
|
+
requestTimeout ? { timeout: requestTimeout } : undefined,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
133
135
|
};
|
|
@@ -1585,6 +1585,150 @@ it("returns 500 when createServer throws non-auth error", async () => {
|
|
|
1585
1585
|
await httpServer.close();
|
|
1586
1586
|
});
|
|
1587
1587
|
|
|
1588
|
+
it("includes WWW-Authenticate header in 401 response with OAuth config", async () => {
|
|
1589
|
+
const port = await getRandomPort();
|
|
1590
|
+
|
|
1591
|
+
const httpServer = await startHTTPServer({
|
|
1592
|
+
createServer: async () => {
|
|
1593
|
+
throw new Error("Invalid JWT token");
|
|
1594
|
+
},
|
|
1595
|
+
oauth: {
|
|
1596
|
+
protectedResource: {
|
|
1597
|
+
resource: "https://example.com",
|
|
1598
|
+
},
|
|
1599
|
+
realm: "mcp-server",
|
|
1600
|
+
},
|
|
1601
|
+
port,
|
|
1602
|
+
stateless: true,
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
const response = await fetch(`http://localhost:${port}/mcp`, {
|
|
1606
|
+
body: JSON.stringify({
|
|
1607
|
+
id: 1,
|
|
1608
|
+
jsonrpc: "2.0",
|
|
1609
|
+
method: "initialize",
|
|
1610
|
+
params: {
|
|
1611
|
+
capabilities: {},
|
|
1612
|
+
clientInfo: { name: "test", version: "1.0.0" },
|
|
1613
|
+
protocolVersion: "2024-11-05",
|
|
1614
|
+
},
|
|
1615
|
+
}),
|
|
1616
|
+
headers: {
|
|
1617
|
+
"Accept": "application/json, text/event-stream",
|
|
1618
|
+
"Content-Type": "application/json",
|
|
1619
|
+
},
|
|
1620
|
+
method: "POST",
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
expect(response.status).toBe(401);
|
|
1624
|
+
|
|
1625
|
+
const wwwAuthHeader = response.headers.get("WWW-Authenticate");
|
|
1626
|
+
expect(wwwAuthHeader).toBeTruthy();
|
|
1627
|
+
expect(wwwAuthHeader).toContain('Bearer');
|
|
1628
|
+
expect(wwwAuthHeader).toContain('realm="mcp-server"');
|
|
1629
|
+
expect(wwwAuthHeader).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"');
|
|
1630
|
+
expect(wwwAuthHeader).toContain('error="invalid_token"');
|
|
1631
|
+
expect(wwwAuthHeader).toContain('error_description="Invalid JWT token"');
|
|
1632
|
+
|
|
1633
|
+
await httpServer.close();
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it("includes WWW-Authenticate header when authenticate callback fails with OAuth", async () => {
|
|
1637
|
+
const port = await getRandomPort();
|
|
1638
|
+
|
|
1639
|
+
const authenticate = vi.fn().mockRejectedValue(new Error("Token signature verification failed"));
|
|
1640
|
+
|
|
1641
|
+
const httpServer = await startHTTPServer({
|
|
1642
|
+
authenticate,
|
|
1643
|
+
createServer: async () => {
|
|
1644
|
+
const mcpServer = new Server(
|
|
1645
|
+
{ name: "test", version: "1.0.0" },
|
|
1646
|
+
{ capabilities: {} },
|
|
1647
|
+
);
|
|
1648
|
+
return mcpServer;
|
|
1649
|
+
},
|
|
1650
|
+
oauth: {
|
|
1651
|
+
error_uri: "https://example.com/docs/errors",
|
|
1652
|
+
protectedResource: {
|
|
1653
|
+
resource: "https://api.example.com",
|
|
1654
|
+
},
|
|
1655
|
+
realm: "example-api",
|
|
1656
|
+
},
|
|
1657
|
+
port,
|
|
1658
|
+
stateless: true,
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
const response = await fetch(`http://localhost:${port}/mcp`, {
|
|
1662
|
+
body: JSON.stringify({
|
|
1663
|
+
id: 1,
|
|
1664
|
+
jsonrpc: "2.0",
|
|
1665
|
+
method: "initialize",
|
|
1666
|
+
params: {
|
|
1667
|
+
capabilities: {},
|
|
1668
|
+
clientInfo: { name: "test", version: "1.0.0" },
|
|
1669
|
+
protocolVersion: "2024-11-05",
|
|
1670
|
+
},
|
|
1671
|
+
}),
|
|
1672
|
+
headers: {
|
|
1673
|
+
"Accept": "application/json, text/event-stream",
|
|
1674
|
+
"Authorization": "Bearer expired-token",
|
|
1675
|
+
"Content-Type": "application/json",
|
|
1676
|
+
},
|
|
1677
|
+
method: "POST",
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
expect(response.status).toBe(401);
|
|
1681
|
+
expect(authenticate).toHaveBeenCalled();
|
|
1682
|
+
|
|
1683
|
+
const wwwAuthHeader = response.headers.get("WWW-Authenticate");
|
|
1684
|
+
expect(wwwAuthHeader).toBeTruthy();
|
|
1685
|
+
expect(wwwAuthHeader).toContain('Bearer');
|
|
1686
|
+
expect(wwwAuthHeader).toContain('realm="example-api"');
|
|
1687
|
+
expect(wwwAuthHeader).toContain('resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"');
|
|
1688
|
+
expect(wwwAuthHeader).toContain('error="invalid_token"');
|
|
1689
|
+
expect(wwwAuthHeader).toContain('error_description="Token signature verification failed"');
|
|
1690
|
+
expect(wwwAuthHeader).toContain('error_uri="https://example.com/docs/errors"');
|
|
1691
|
+
|
|
1692
|
+
await httpServer.close();
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
it("does not include WWW-Authenticate header in 401 response without OAuth config", async () => {
|
|
1696
|
+
const port = await getRandomPort();
|
|
1697
|
+
|
|
1698
|
+
const httpServer = await startHTTPServer({
|
|
1699
|
+
createServer: async () => {
|
|
1700
|
+
throw new Error("Authentication required");
|
|
1701
|
+
},
|
|
1702
|
+
port,
|
|
1703
|
+
stateless: true,
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
const response = await fetch(`http://localhost:${port}/mcp`, {
|
|
1707
|
+
body: JSON.stringify({
|
|
1708
|
+
id: 1,
|
|
1709
|
+
jsonrpc: "2.0",
|
|
1710
|
+
method: "initialize",
|
|
1711
|
+
params: {
|
|
1712
|
+
capabilities: {},
|
|
1713
|
+
clientInfo: { name: "test", version: "1.0.0" },
|
|
1714
|
+
protocolVersion: "2024-11-05",
|
|
1715
|
+
},
|
|
1716
|
+
}),
|
|
1717
|
+
headers: {
|
|
1718
|
+
"Accept": "application/json, text/event-stream",
|
|
1719
|
+
"Content-Type": "application/json",
|
|
1720
|
+
},
|
|
1721
|
+
method: "POST",
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
expect(response.status).toBe(401);
|
|
1725
|
+
|
|
1726
|
+
const wwwAuthHeader = response.headers.get("WWW-Authenticate");
|
|
1727
|
+
expect(wwwAuthHeader).toBeNull();
|
|
1728
|
+
|
|
1729
|
+
await httpServer.close();
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1588
1732
|
it("succeeds when authenticate returns { authenticated: true } in stateless mode", async () => {
|
|
1589
1733
|
const stdioTransport = new StdioClientTransport({
|
|
1590
1734
|
args: ["src/fixtures/simple-stdio-server.ts"],
|