@zereight/mcp-gitlab 2.0.34 → 2.0.35
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 +221 -89
- package/build/gitlab-client-pool.js +6 -0
- package/build/index.js +2129 -34
- package/build/oauth-proxy.js +257 -0
- package/build/schemas.js +455 -199
- package/build/test/mcp-oauth-tests.js +443 -0
- package/build/test/multi-server-test.js +16 -8
- package/build/test/test-geteffectiveprojectid.js +211 -202
- package/build/test/test-mr-file-diffs.js +251 -0
- package/build/test/test-search-code.js +272 -0
- package/build/test/test-toolset-filtering.js +22 -17
- package/build/test/utils/mock-gitlab-server.js +263 -163
- package/build/test/utils/server-launcher.js +45 -41
- package/package.json +3 -2
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Tests
|
|
3
|
+
* Tests for the GITLAB_MCP_OAUTH=true server-side OAuth proxy mode.
|
|
4
|
+
*
|
|
5
|
+
* The suite uses a mock GitLab server that implements the minimal OAuth
|
|
6
|
+
* endpoints (token/info, DCR register, well-known metadata) so no live
|
|
7
|
+
* GitLab instance is required.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, after, before } from "node:test";
|
|
10
|
+
import assert from "node:assert";
|
|
11
|
+
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
12
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Constants
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const MOCK_OAUTH_TOKEN = "ya29.mock-oauth-token-abcdef123456";
|
|
17
|
+
const MOCK_CLIENT_ID = "mock-app-uid-from-dcr";
|
|
18
|
+
const MOCK_GITLAB_PORT_BASE = 9200;
|
|
19
|
+
const MCP_SERVER_PORT_BASE = 3200;
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/**
|
|
24
|
+
* Add minimal OAuth endpoints to a MockGitLabServer.
|
|
25
|
+
*
|
|
26
|
+
* GitLab serves these at the instance root (not under /api/v4), so we use
|
|
27
|
+
* addRootHandler rather than addMockHandler.
|
|
28
|
+
*
|
|
29
|
+
* Endpoints added:
|
|
30
|
+
* GET /oauth/token/info — token introspection (Bearer header required)
|
|
31
|
+
* POST /oauth/register — Dynamic Client Registration stub
|
|
32
|
+
* GET /.well-known/oauth-authorization-server — AS metadata
|
|
33
|
+
*/
|
|
34
|
+
function addOAuthEndpoints(mockGitLab, validToken, clientId, baseUrl) {
|
|
35
|
+
// Token introspection — called by verifyAccessToken()
|
|
36
|
+
mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => {
|
|
37
|
+
const auth = req.headers["authorization"];
|
|
38
|
+
const token = auth?.replace(/^Bearer\s+/i, "");
|
|
39
|
+
if (token !== validToken) {
|
|
40
|
+
res.status(401).json({ error: "invalid_token" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
res.json({
|
|
44
|
+
resource_owner_id: 42,
|
|
45
|
+
scopes: ["api"],
|
|
46
|
+
expires_in_seconds: 7200,
|
|
47
|
+
application: { uid: clientId },
|
|
48
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
// Dynamic Client Registration — proxied by mcpAuthRouter
|
|
52
|
+
mockGitLab.addRootHandler("post", "/oauth/register", (req, res) => {
|
|
53
|
+
res.status(201).json({
|
|
54
|
+
client_id: clientId,
|
|
55
|
+
client_name: req.body?.client_name ?? "test",
|
|
56
|
+
redirect_uris: req.body?.redirect_uris ?? [],
|
|
57
|
+
token_endpoint_auth_method: "none",
|
|
58
|
+
require_pkce: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
// OAuth Authorization Server well-known metadata
|
|
62
|
+
mockGitLab.addRootHandler("get", "/.well-known/oauth-authorization-server", (_req, res) => {
|
|
63
|
+
res.json({
|
|
64
|
+
issuer: baseUrl,
|
|
65
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
66
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
67
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
68
|
+
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
|
69
|
+
scopes_supported: ["api", "read_api", "read_user"],
|
|
70
|
+
response_types_supported: ["code"],
|
|
71
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
72
|
+
code_challenge_methods_supported: ["S256"],
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Test suite: Discovery endpoints
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
describe("MCP OAuth — Discovery Endpoints", () => {
|
|
80
|
+
let mcpUrl;
|
|
81
|
+
let mcpBaseUrl;
|
|
82
|
+
let mockGitLab;
|
|
83
|
+
let servers = [];
|
|
84
|
+
before(async () => {
|
|
85
|
+
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
|
|
86
|
+
mockGitLab = new MockGitLabServer({
|
|
87
|
+
port: mockPort,
|
|
88
|
+
validTokens: [MOCK_OAUTH_TOKEN],
|
|
89
|
+
});
|
|
90
|
+
await mockGitLab.start();
|
|
91
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
92
|
+
addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
|
|
93
|
+
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
94
|
+
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
95
|
+
mcpUrl = `${mcpBaseUrl}/mcp`;
|
|
96
|
+
const server = await launchServer({
|
|
97
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
98
|
+
port: mcpPort,
|
|
99
|
+
timeout: 5000,
|
|
100
|
+
env: {
|
|
101
|
+
STREAMABLE_HTTP: "true",
|
|
102
|
+
GITLAB_MCP_OAUTH: "true",
|
|
103
|
+
GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
|
|
104
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
105
|
+
MCP_SERVER_URL: mcpBaseUrl,
|
|
106
|
+
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
servers.push(server);
|
|
110
|
+
console.log(`Mock GitLab: ${mockGitLabUrl}`);
|
|
111
|
+
console.log(`MCP Server: ${mcpBaseUrl}`);
|
|
112
|
+
});
|
|
113
|
+
after(async () => {
|
|
114
|
+
cleanupServers(servers);
|
|
115
|
+
if (mockGitLab) {
|
|
116
|
+
await mockGitLab.stop();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test("GET /.well-known/oauth-authorization-server returns AS metadata", async () => {
|
|
120
|
+
const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-authorization-server`);
|
|
121
|
+
assert.strictEqual(res.status, 200, "Should return 200");
|
|
122
|
+
const body = (await res.json());
|
|
123
|
+
assert.ok(body.issuer, "Should have issuer");
|
|
124
|
+
assert.ok(body.authorization_endpoint, "Should have authorization_endpoint");
|
|
125
|
+
assert.ok(body.token_endpoint, "Should have token_endpoint");
|
|
126
|
+
assert.ok(body.registration_endpoint, "Should have registration_endpoint");
|
|
127
|
+
console.log(" ✓ AS metadata returned with all required fields");
|
|
128
|
+
});
|
|
129
|
+
test("GET /.well-known/oauth-protected-resource returns resource metadata", async () => {
|
|
130
|
+
// The SDK mounts the protected resource metadata at
|
|
131
|
+
// /.well-known/oauth-protected-resource{pathname} where pathname is derived
|
|
132
|
+
// from resourceServerUrl (or issuerUrl). With issuerUrl = "http://host:port/"
|
|
133
|
+
// the pathname "/" is stripped, yielding the bare endpoint.
|
|
134
|
+
const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource`);
|
|
135
|
+
assert.strictEqual(res.status, 200, "Should return 200");
|
|
136
|
+
const body = (await res.json());
|
|
137
|
+
assert.ok(body.resource, "Should have resource field");
|
|
138
|
+
console.log(" ✓ Protected resource metadata returned");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Test suite: /mcp auth enforcement
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
describe("MCP OAuth — /mcp Auth Enforcement", () => {
|
|
145
|
+
let mcpUrl;
|
|
146
|
+
let mcpBaseUrl;
|
|
147
|
+
let mockGitLab;
|
|
148
|
+
let servers = [];
|
|
149
|
+
before(async () => {
|
|
150
|
+
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 50);
|
|
151
|
+
mockGitLab = new MockGitLabServer({
|
|
152
|
+
port: mockPort,
|
|
153
|
+
validTokens: [MOCK_OAUTH_TOKEN],
|
|
154
|
+
});
|
|
155
|
+
await mockGitLab.start();
|
|
156
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
157
|
+
addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
|
|
158
|
+
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 50);
|
|
159
|
+
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
160
|
+
mcpUrl = `${mcpBaseUrl}/mcp`;
|
|
161
|
+
const server = await launchServer({
|
|
162
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
163
|
+
port: mcpPort,
|
|
164
|
+
timeout: 5000,
|
|
165
|
+
env: {
|
|
166
|
+
STREAMABLE_HTTP: "true",
|
|
167
|
+
GITLAB_MCP_OAUTH: "true",
|
|
168
|
+
GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
|
|
169
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
170
|
+
MCP_SERVER_URL: mcpBaseUrl,
|
|
171
|
+
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
servers.push(server);
|
|
175
|
+
});
|
|
176
|
+
after(async () => {
|
|
177
|
+
cleanupServers(servers);
|
|
178
|
+
if (mockGitLab) {
|
|
179
|
+
await mockGitLab.stop();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
test("POST /mcp without Authorization header returns 401 with WWW-Authenticate", async () => {
|
|
183
|
+
const res = await fetch(mcpUrl, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
187
|
+
});
|
|
188
|
+
assert.strictEqual(res.status, 401, "Should return 401 Unauthorized");
|
|
189
|
+
const wwwAuth = res.headers.get("www-authenticate");
|
|
190
|
+
assert.ok(wwwAuth, "Should have WWW-Authenticate header");
|
|
191
|
+
assert.ok(wwwAuth.toLowerCase().startsWith("bearer"), "WWW-Authenticate should use Bearer scheme");
|
|
192
|
+
console.log(" ✓ 401 returned with WWW-Authenticate header (no auth)");
|
|
193
|
+
console.log(` ℹ️ WWW-Authenticate: ${wwwAuth}`);
|
|
194
|
+
});
|
|
195
|
+
test("POST /mcp with invalid token returns 401", async () => {
|
|
196
|
+
const res = await fetch(mcpUrl, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: {
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
Authorization: "Bearer invalid-token-xyz",
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
203
|
+
});
|
|
204
|
+
assert.strictEqual(res.status, 401, "Should return 401 for invalid token");
|
|
205
|
+
console.log(" ✓ 401 returned for invalid OAuth token");
|
|
206
|
+
});
|
|
207
|
+
test("POST /mcp with valid Bearer token returns non-401", async () => {
|
|
208
|
+
const res = await fetch(mcpUrl, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
"Content-Type": "application/json",
|
|
212
|
+
Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`,
|
|
213
|
+
Accept: "application/json, text/event-stream",
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
jsonrpc: "2.0",
|
|
217
|
+
id: 1,
|
|
218
|
+
method: "initialize",
|
|
219
|
+
params: {
|
|
220
|
+
protocolVersion: "2024-11-05",
|
|
221
|
+
capabilities: {},
|
|
222
|
+
clientInfo: { name: "test-client", version: "1.0.0" },
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
assert.notStrictEqual(res.status, 401, "Should not return 401 with valid token");
|
|
227
|
+
assert.notStrictEqual(res.status, 403, "Should not return 403 with valid token");
|
|
228
|
+
console.log(` ✓ Valid token accepted (status: ${res.status})`);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Test suite: BoundedClientCache unit tests
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
describe("MCP OAuth — BoundedClientCache", () => {
|
|
235
|
+
// Access the internal class via a minimal provider (it's not exported directly)
|
|
236
|
+
// by driving it through the public clientsStore API.
|
|
237
|
+
function makeClient(id, redirectUri = "https://example.com/cb") {
|
|
238
|
+
return {
|
|
239
|
+
client_id: id,
|
|
240
|
+
redirect_uris: [redirectUri],
|
|
241
|
+
token_endpoint_auth_method: "none",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async function buildCachingProvider() {
|
|
245
|
+
// Spin up a DCR stub that returns a stable client_id from the request body.
|
|
246
|
+
// The stub reads client_id from the incoming body (set by the SDK's register
|
|
247
|
+
// handler before calling registerClient), so cache keys are predictable.
|
|
248
|
+
const { createServer } = await import("node:http");
|
|
249
|
+
const stub = createServer((req, res) => {
|
|
250
|
+
let body = "";
|
|
251
|
+
req.on("data", chunk => (body += chunk));
|
|
252
|
+
req.on("end", () => {
|
|
253
|
+
const parsed = JSON.parse(body || "{}");
|
|
254
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
255
|
+
// Echo client_id back so tests can look it up by a known key
|
|
256
|
+
res.end(JSON.stringify({
|
|
257
|
+
client_id: parsed.client_id ?? "unknown",
|
|
258
|
+
client_name: parsed.client_name ?? "unnamed",
|
|
259
|
+
redirect_uris: parsed.redirect_uris ?? [],
|
|
260
|
+
token_endpoint_auth_method: "none",
|
|
261
|
+
}));
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
|
|
265
|
+
const addr = stub.address();
|
|
266
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
267
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
268
|
+
const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
|
|
269
|
+
return { provider, stub };
|
|
270
|
+
}
|
|
271
|
+
test("LRU: both clients remain cached after sequential registration", async () => {
|
|
272
|
+
const { provider, stub } = await buildCachingProvider();
|
|
273
|
+
try {
|
|
274
|
+
const store = provider.clientsStore;
|
|
275
|
+
// Register two clients — the provider generates a random UUID as client_id,
|
|
276
|
+
// so we capture the returned client_id to look up the cache.
|
|
277
|
+
const regA = await store.registerClient({
|
|
278
|
+
client_name: "Claude",
|
|
279
|
+
redirect_uris: ["https://a.com/cb"],
|
|
280
|
+
token_endpoint_auth_method: "none",
|
|
281
|
+
});
|
|
282
|
+
const regB = await store.registerClient({
|
|
283
|
+
client_name: "Cursor",
|
|
284
|
+
redirect_uris: ["https://b.com/cb"],
|
|
285
|
+
token_endpoint_auth_method: "none",
|
|
286
|
+
});
|
|
287
|
+
const a = await store.getClient(regA.client_id);
|
|
288
|
+
const b = await store.getClient(regB.client_id);
|
|
289
|
+
assert.deepStrictEqual(a.redirect_uris, ["https://a.com/cb"], "client-A still cached");
|
|
290
|
+
assert.deepStrictEqual(b.redirect_uris, ["https://b.com/cb"], "client-B still cached");
|
|
291
|
+
console.log(" ✓ Both clients remain cached after sequential registration");
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
stub.close();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
test("cache: re-registration returns a new entry with its own client_id", async () => {
|
|
298
|
+
const { provider, stub } = await buildCachingProvider();
|
|
299
|
+
try {
|
|
300
|
+
const store = provider.clientsStore;
|
|
301
|
+
const first = await store.registerClient({
|
|
302
|
+
client_name: "Claude",
|
|
303
|
+
redirect_uris: ["https://old.com/cb"],
|
|
304
|
+
token_endpoint_auth_method: "none",
|
|
305
|
+
});
|
|
306
|
+
const firstLookup = await store.getClient(first.client_id);
|
|
307
|
+
assert.deepStrictEqual(firstLookup.redirect_uris, ["https://old.com/cb"]);
|
|
308
|
+
const second = await store.registerClient({
|
|
309
|
+
client_name: "Claude",
|
|
310
|
+
redirect_uris: ["https://new.com/cb"],
|
|
311
|
+
token_endpoint_auth_method: "none",
|
|
312
|
+
});
|
|
313
|
+
const secondLookup = await store.getClient(second.client_id);
|
|
314
|
+
assert.deepStrictEqual(secondLookup.redirect_uris, ["https://new.com/cb"], "New registration cached under its own client_id");
|
|
315
|
+
// Both entries remain accessible
|
|
316
|
+
const firstStill = await store.getClient(first.client_id);
|
|
317
|
+
assert.deepStrictEqual(firstStill.redirect_uris, ["https://old.com/cb"], "First entry still cached");
|
|
318
|
+
console.log(" ✓ Re-registration creates a new cached entry");
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
stub.close();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Test suite: createGitLabOAuthProvider unit tests
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
describe("MCP OAuth — createGitLabOAuthProvider", () => {
|
|
329
|
+
test("verifyAccessToken throws on non-OK response", async () => {
|
|
330
|
+
// Spin up a tiny local server that always returns 401
|
|
331
|
+
const { createServer } = await import("node:http");
|
|
332
|
+
const stub = createServer((req, res) => {
|
|
333
|
+
res.writeHead(401);
|
|
334
|
+
res.end(JSON.stringify({ error: "invalid_token" }));
|
|
335
|
+
});
|
|
336
|
+
await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
|
|
337
|
+
const addr = stub.address();
|
|
338
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
339
|
+
try {
|
|
340
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
341
|
+
const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
|
|
342
|
+
await assert.rejects(() => provider.verifyAccessToken("bad-token"), /invalid or expired/i, "Should throw InvalidTokenError for non-OK response");
|
|
343
|
+
console.log(" ✓ verifyAccessToken throws for 401 from GitLab");
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
stub.close();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
test("verifyAccessToken maps GitLab token info to AuthInfo", async () => {
|
|
350
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
351
|
+
const { createServer } = await import("node:http");
|
|
352
|
+
const stub = createServer((_req, res) => {
|
|
353
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
354
|
+
res.end(JSON.stringify({
|
|
355
|
+
resource_owner_id: 7,
|
|
356
|
+
scopes: ["api", "read_user"],
|
|
357
|
+
expires_in_seconds: 3600,
|
|
358
|
+
application: { uid: "app-uid-abc" },
|
|
359
|
+
created_at: createdAt,
|
|
360
|
+
}));
|
|
361
|
+
});
|
|
362
|
+
await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
|
|
363
|
+
const addr = stub.address();
|
|
364
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
365
|
+
try {
|
|
366
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
367
|
+
const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
|
|
368
|
+
const authInfo = await provider.verifyAccessToken("good-token");
|
|
369
|
+
assert.strictEqual(authInfo.token, "good-token", "token must be preserved");
|
|
370
|
+
assert.strictEqual(authInfo.clientId, "app-uid-abc", "clientId from application.uid");
|
|
371
|
+
assert.deepStrictEqual(authInfo.scopes, ["api", "read_user"], "scopes forwarded");
|
|
372
|
+
assert.ok(typeof authInfo.expiresAt === "number", "expiresAt must be a number");
|
|
373
|
+
assert.ok(authInfo.expiresAt > Math.floor(Date.now() / 1000), "expiresAt must be in the future");
|
|
374
|
+
console.log(" ✓ verifyAccessToken returns correct AuthInfo");
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
stub.close();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
test("verifyAccessToken uses 'dynamic' clientId when application is null", async () => {
|
|
381
|
+
const { createServer } = await import("node:http");
|
|
382
|
+
const stub = createServer((_req, res) => {
|
|
383
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
384
|
+
res.end(JSON.stringify({
|
|
385
|
+
resource_owner_id: 1,
|
|
386
|
+
scopes: ["read_api"],
|
|
387
|
+
expires_in_seconds: null,
|
|
388
|
+
application: null,
|
|
389
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
390
|
+
}));
|
|
391
|
+
});
|
|
392
|
+
await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
|
|
393
|
+
const addr = stub.address();
|
|
394
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
395
|
+
try {
|
|
396
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
397
|
+
const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
|
|
398
|
+
const authInfo = await provider.verifyAccessToken("tok");
|
|
399
|
+
assert.strictEqual(authInfo.clientId, "dynamic", "clientId should fall back to 'dynamic'");
|
|
400
|
+
assert.strictEqual(authInfo.expiresAt, undefined, "expiresAt should be undefined when null");
|
|
401
|
+
console.log(" ✓ null application and null expires_in_seconds handled correctly");
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
stub.close();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
test("getClient returns stub for unknown clientId", async () => {
|
|
408
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
409
|
+
const provider = createGitLabOAuthProvider("https://gitlab.example.com", "test-app-id");
|
|
410
|
+
const client = await provider.clientsStore.getClient("unknown-client-id");
|
|
411
|
+
assert.ok(client, "Should return a client object");
|
|
412
|
+
assert.strictEqual(client.client_id, "unknown-client-id", "client_id should match input");
|
|
413
|
+
assert.deepStrictEqual(client.redirect_uris, [], "redirect_uris should be empty for unknown client");
|
|
414
|
+
assert.strictEqual(client.token_endpoint_auth_method, "none", "Should be a public client");
|
|
415
|
+
console.log(" ✓ getClient returns stub for unknown clientId");
|
|
416
|
+
});
|
|
417
|
+
test("clientsStore caches DCR response so getClient returns real redirect_uris", async () => {
|
|
418
|
+
const REGISTERED_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback";
|
|
419
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
420
|
+
const provider = createGitLabOAuthProvider("https://gitlab.example.com", "test-app-id", "My MCP Server");
|
|
421
|
+
// Before registration: getClient returns a stub with empty redirect_uris
|
|
422
|
+
const beforeReg = await provider.clientsStore.getClient("some-unknown-id");
|
|
423
|
+
assert.deepStrictEqual(beforeReg.redirect_uris, [], "Should be empty before registration");
|
|
424
|
+
// Simulate DCR registration (as the SDK would call it).
|
|
425
|
+
// The provider generates a virtual client_id (UUID) — it does NOT proxy to upstream.
|
|
426
|
+
const registered = await provider.clientsStore.registerClient({
|
|
427
|
+
client_name: "Claude",
|
|
428
|
+
redirect_uris: [REGISTERED_REDIRECT_URI],
|
|
429
|
+
token_endpoint_auth_method: "none",
|
|
430
|
+
});
|
|
431
|
+
// client_id should be a UUID generated by the provider
|
|
432
|
+
assert.ok(registered.client_id, "client_id should be set");
|
|
433
|
+
assert.match(registered.client_id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "client_id should be a UUID");
|
|
434
|
+
assert.deepStrictEqual(registered.redirect_uris, [REGISTERED_REDIRECT_URI], "redirect_uris preserved from input");
|
|
435
|
+
// client_name should be annotated with the resource name
|
|
436
|
+
assert.ok(registered.client_name?.includes("Claude via My MCP Server"), `client_name should include 'Claude via My MCP Server', got: ${registered.client_name}`);
|
|
437
|
+
// After registration: getClient with the returned client_id should return the cached entry
|
|
438
|
+
const afterReg = await provider.clientsStore.getClient(registered.client_id);
|
|
439
|
+
assert.deepStrictEqual(afterReg.redirect_uris, [REGISTERED_REDIRECT_URI], "getClient should return real redirect_uris from cache after registration");
|
|
440
|
+
console.log(" ✓ DCR response cached: getClient returns real redirect_uris after registration");
|
|
441
|
+
console.log(` ✓ client_name annotated: ${registered.client_name}`);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -164,19 +164,27 @@ describe("Dynamic Client Mode (ENABLE_DYNAMIC_API_URL=true)", () => {
|
|
|
164
164
|
}
|
|
165
165
|
await client.disconnect();
|
|
166
166
|
});
|
|
167
|
-
test("should
|
|
167
|
+
test("should fail tool call when header contains a non-whitelisted URL", async () => {
|
|
168
168
|
const client = new CustomHeaderClient({
|
|
169
169
|
headers: {
|
|
170
170
|
'authorization': `Bearer ${MOCK_TOKEN}`,
|
|
171
171
|
'X-GitLab-API-URL': 'http://localhost:9999/api/v4',
|
|
172
172
|
}
|
|
173
173
|
});
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
174
|
+
// Connect succeeds (server accepts any valid URL format),
|
|
175
|
+
// but tool calls fail because the target server doesn't exist
|
|
176
|
+
await client.connect(mcpUrl);
|
|
177
|
+
try {
|
|
178
|
+
const result = await client.callTool('get_project', { project_id: "1" });
|
|
179
|
+
// If callTool returns (doesn't throw), check for error in response
|
|
180
|
+
const content = result.content[0];
|
|
181
|
+
assert.ok('text' in content, 'Should have text content');
|
|
182
|
+
assert.ok(content.text.includes('Error') || content.text.includes('error') || content.text.includes('ECONNREFUSED') || result.isError, 'Should return an error for unreachable server');
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
// MCP client may throw for server-side errors (-32603)
|
|
186
|
+
assert.ok(err.message.includes('ECONNREFUSED') || err.message.includes('failed') || err.message.includes('error'), `Should contain connection error info: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
await client.disconnect();
|
|
181
189
|
});
|
|
182
190
|
});
|