@zereight/mcp-gitlab 2.1.3 → 2.1.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/README.md +5 -0
- package/build/config.js +1 -0
- package/build/index.js +30 -7
- package/build/oauth-proxy.js +250 -46
- package/build/schemas.js +8 -2
- package/build/test/callback-proxy-tests.js +321 -0
- package/build/test/dynamic-routing-tests.js +49 -0
- package/build/test/schema-tests.js +76 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -173,12 +173,14 @@ exchanging credentials with GitLab on behalf of the client.
|
|
|
173
173
|
| `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
|
|
174
174
|
| `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
|
|
175
175
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
176
|
+
| `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL |
|
|
176
177
|
| `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
|
|
177
178
|
|
|
178
179
|
```shell
|
|
179
180
|
docker run -i --rm \
|
|
180
181
|
-e HOST=0.0.0.0 \
|
|
181
182
|
-e GITLAB_MCP_OAUTH=true \
|
|
183
|
+
-e GITLAB_OAUTH_CALLBACK_PROXY=true \
|
|
182
184
|
-e STREAMABLE_HTTP=true \
|
|
183
185
|
-e MCP_SERVER_URL=https://your-server.example.com \
|
|
184
186
|
-e GITLAB_API_URL="https://gitlab.com/api/v4" \
|
|
@@ -249,6 +251,7 @@ Commonly referenced variables:
|
|
|
249
251
|
- `GITLAB_USE_OAUTH`
|
|
250
252
|
- `REMOTE_AUTHORIZATION`
|
|
251
253
|
- `GITLAB_MCP_OAUTH`
|
|
254
|
+
- `GITLAB_OAUTH_CALLBACK_PROXY`
|
|
252
255
|
|
|
253
256
|
The reference document also covers:
|
|
254
257
|
|
|
@@ -259,6 +262,8 @@ The reference document also covers:
|
|
|
259
262
|
- transport and session variables
|
|
260
263
|
- proxy and TLS variables
|
|
261
264
|
|
|
265
|
+
For callback proxy mode details, see [GitLab MCP OAuth Callback Proxy](./docs/oauth-callback-proxy.md).
|
|
266
|
+
|
|
262
267
|
### Remote Authorization Setup (Multi-User Support)
|
|
263
268
|
|
|
264
269
|
When using `REMOTE_AUTHORIZATION=true`, the MCP server can support multiple users, each with their own GitLab token passed via HTTP headers. This is useful for:
|
package/build/config.js
CHANGED
|
@@ -56,6 +56,7 @@ export const GITLAB_OAUTH_SCOPES_RAW = getConfig("oauth-scopes", "GITLAB_OAUTH_S
|
|
|
56
56
|
export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
|
|
57
57
|
? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
|
|
58
58
|
: undefined;
|
|
59
|
+
export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
|
|
59
60
|
export const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
60
61
|
// ---------------------------------------------------------------------------
|
|
61
62
|
// Session / server settings
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
|
|
2
|
+
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -4863,9 +4863,8 @@ async function cancelPipelineJob(projectId, jobId, force) {
|
|
|
4863
4863
|
}
|
|
4864
4864
|
/**
|
|
4865
4865
|
* Get the repository tree for a project
|
|
4866
|
-
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4867
4866
|
* @param {GetRepositoryTreeOptions} options - Options for the tree
|
|
4868
|
-
* @returns
|
|
4867
|
+
* @returns Parsed tree items plus optional keyset pagination metadata.
|
|
4869
4868
|
*/
|
|
4870
4869
|
async function getRepositoryTree(options) {
|
|
4871
4870
|
options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options
|
|
@@ -4890,7 +4889,11 @@ async function getRepositoryTree(options) {
|
|
|
4890
4889
|
throw new Error(`Failed to get repository tree: ${response.statusText}`);
|
|
4891
4890
|
}
|
|
4892
4891
|
const data = await response.json();
|
|
4893
|
-
|
|
4892
|
+
const items = z.array(GitLabTreeItemSchema).parse(data);
|
|
4893
|
+
const next_page_token = response.headers.get("x-next-page-token") ||
|
|
4894
|
+
(options.pagination === "keyset" ? response.headers.get("x-next-page") : null) ||
|
|
4895
|
+
undefined;
|
|
4896
|
+
return { items, next_page_token };
|
|
4894
4897
|
}
|
|
4895
4898
|
/**
|
|
4896
4899
|
* List project milestones in a GitLab project
|
|
@@ -6583,9 +6586,18 @@ async function handleToolCall(params) {
|
|
|
6583
6586
|
}
|
|
6584
6587
|
case "get_repository_tree": {
|
|
6585
6588
|
const args = GetRepositoryTreeSchema.parse(params.arguments);
|
|
6586
|
-
const
|
|
6589
|
+
const { items, next_page_token } = await getRepositoryTree(args);
|
|
6590
|
+
const result = args.pagination === "keyset" || next_page_token
|
|
6591
|
+
? {
|
|
6592
|
+
items,
|
|
6593
|
+
...(next_page_token ? { next_page_token } : {}),
|
|
6594
|
+
pagination_note: next_page_token
|
|
6595
|
+
? "Pass next_page_token as page_token with pagination=keyset to retrieve the next page."
|
|
6596
|
+
: "No next_page_token was returned; this is the final keyset page.",
|
|
6597
|
+
}
|
|
6598
|
+
: items;
|
|
6587
6599
|
return {
|
|
6588
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
6600
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
6589
6601
|
};
|
|
6590
6602
|
}
|
|
6591
6603
|
case "list_pipelines": {
|
|
@@ -7359,7 +7371,10 @@ async function startStreamableHTTPServer() {
|
|
|
7359
7371
|
app.set("trust proxy", 1);
|
|
7360
7372
|
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
|
|
7361
7373
|
const issuerUrl = new URL(MCP_SERVER_URL);
|
|
7362
|
-
const
|
|
7374
|
+
const callbackUrl = GITLAB_OAUTH_CALLBACK_PROXY
|
|
7375
|
+
? `${issuerUrl.origin}${issuerUrl.pathname.replace(/\/$/, "")}/callback`
|
|
7376
|
+
: undefined;
|
|
7377
|
+
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl);
|
|
7363
7378
|
const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
|
|
7364
7379
|
// When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
|
|
7365
7380
|
// advertises root-level endpoints. Override to use path-prefixed endpoints.
|
|
@@ -7412,6 +7427,14 @@ async function startStreamableHTTPServer() {
|
|
|
7412
7427
|
}));
|
|
7413
7428
|
// Expose provider so the /mcp route middleware can reference it
|
|
7414
7429
|
app._mcpOAuthProvider = oauthProvider;
|
|
7430
|
+
// Mount /callback route for callback proxy mode
|
|
7431
|
+
if (GITLAB_OAUTH_CALLBACK_PROXY) {
|
|
7432
|
+
const callbackPath = `${issuerUrl.pathname.replace(/\/$/, "")}/callback`;
|
|
7433
|
+
app.get(callbackPath, (req, res, next) => {
|
|
7434
|
+
oauthProvider.handleCallback(req, res).catch(next);
|
|
7435
|
+
});
|
|
7436
|
+
logger.info(`Callback proxy mode enabled — ${callbackPath} route mounted`);
|
|
7437
|
+
}
|
|
7415
7438
|
}
|
|
7416
7439
|
// Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
|
|
7417
7440
|
// Unauthenticated requests receive 401 + WWW-Authenticate header, which is
|
package/build/oauth-proxy.js
CHANGED
|
@@ -13,7 +13,26 @@
|
|
|
13
13
|
* and handles DCR locally — each MCP client gets a unique virtual client_id
|
|
14
14
|
* mapped to the real GitLab app.
|
|
15
15
|
*
|
|
16
|
-
* ### Flow
|
|
16
|
+
* ### Flow (callback proxy mode — GITLAB_OAUTH_CALLBACK_PROXY=true)
|
|
17
|
+
*
|
|
18
|
+
* When callback proxy mode is enabled, the MCP server acts as a full OAuth
|
|
19
|
+
* intermediary, similar to the Atlassian MCP's OAuthProxy pattern. Only ONE
|
|
20
|
+
* fixed callback URL needs to be registered with GitLab, regardless of how
|
|
21
|
+
* many MCP clients connect.
|
|
22
|
+
*
|
|
23
|
+
* 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally
|
|
24
|
+
* and returns a virtual client_id.
|
|
25
|
+
* 2. MCP client redirects to /authorize — proxy stores the client's original
|
|
26
|
+
* redirect_uri and state, generates its own PKCE pair, then redirects to
|
|
27
|
+
* GitLab using the MCP server's fixed /callback URL as redirect_uri.
|
|
28
|
+
* 3. User authorizes on GitLab — GitLab redirects to the MCP server's /callback.
|
|
29
|
+
* 4. /callback handler exchanges the code with GitLab for tokens, stores them
|
|
30
|
+
* server-side, generates a new proxy auth code, and redirects to the client's
|
|
31
|
+
* original redirect_uri with the proxy code.
|
|
32
|
+
* 5. MCP client calls POST /token with the proxy code — proxy returns the
|
|
33
|
+
* stored GitLab tokens.
|
|
34
|
+
*
|
|
35
|
+
* ### Flow (passthrough mode — default)
|
|
17
36
|
*
|
|
18
37
|
* 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally
|
|
19
38
|
* and returns a virtual client_id.
|
|
@@ -27,62 +46,81 @@
|
|
|
27
46
|
*/
|
|
28
47
|
import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
29
48
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
30
|
-
import { randomUUID } from "node:crypto";
|
|
49
|
+
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
31
50
|
import { pino } from "pino";
|
|
32
51
|
const logger = pino({ name: "gitlab-mcp-oauth-proxy" });
|
|
33
52
|
// ---------------------------------------------------------------------------
|
|
34
|
-
//
|
|
53
|
+
// GitLab OAuth Server Provider
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* Minimum GitLab scopes required for the MCP server to function.
|
|
57
|
+
* Injected into the authorization request when the client does not request them.
|
|
58
|
+
*/
|
|
59
|
+
const REQUIRED_GITLAB_SCOPES_RW = ["api"];
|
|
60
|
+
const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Callback proxy mode — pending auth transactions
|
|
35
63
|
// ---------------------------------------------------------------------------
|
|
64
|
+
const PENDING_AUTH_MAX_SIZE = 1000;
|
|
65
|
+
const PENDING_AUTH_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
36
66
|
const CLIENT_CACHE_MAX_SIZE = 1000;
|
|
37
|
-
class
|
|
67
|
+
class BoundedLRUMap {
|
|
38
68
|
_map = new Map();
|
|
39
69
|
_maxSize;
|
|
40
70
|
constructor(maxSize) {
|
|
41
71
|
this._maxSize = maxSize;
|
|
42
72
|
}
|
|
43
|
-
get(
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
this._map.delete(
|
|
47
|
-
this._map.set(
|
|
73
|
+
get(key) {
|
|
74
|
+
const v = this._map.get(key);
|
|
75
|
+
if (v !== undefined) {
|
|
76
|
+
this._map.delete(key);
|
|
77
|
+
this._map.set(key, v);
|
|
48
78
|
}
|
|
49
|
-
return
|
|
79
|
+
return v;
|
|
50
80
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
/** Get and remove in one operation — for one-time-use entries. */
|
|
82
|
+
getAndDelete(key) {
|
|
83
|
+
const v = this._map.get(key);
|
|
84
|
+
if (v !== undefined)
|
|
85
|
+
this._map.delete(key);
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
88
|
+
set(key, value) {
|
|
89
|
+
if (this._map.has(key))
|
|
90
|
+
this._map.delete(key);
|
|
55
91
|
else if (this._map.size >= this._maxSize) {
|
|
56
92
|
const lruKey = this._map.keys().next().value;
|
|
57
93
|
if (lruKey !== undefined)
|
|
58
94
|
this._map.delete(lruKey);
|
|
59
95
|
}
|
|
60
|
-
this._map.set(
|
|
96
|
+
this._map.set(key, value);
|
|
97
|
+
}
|
|
98
|
+
delete(key) {
|
|
99
|
+
return this._map.delete(key);
|
|
61
100
|
}
|
|
62
101
|
get size() {
|
|
63
102
|
return this._map.size;
|
|
64
103
|
}
|
|
65
104
|
}
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// GitLab OAuth Server Provider
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
/**
|
|
70
|
-
* Minimum GitLab scopes required for the MCP server to function.
|
|
71
|
-
* Injected into the authorization request when the client does not request them.
|
|
72
|
-
*/
|
|
73
|
-
const REQUIRED_GITLAB_SCOPES_RW = ["api"];
|
|
74
|
-
const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
|
|
75
105
|
class GitLabOAuthServerProvider {
|
|
76
106
|
/**
|
|
77
|
-
* Tell the SDK not to validate PKCE locally
|
|
107
|
+
* Tell the SDK not to validate PKCE locally.
|
|
108
|
+
* - Passthrough mode: GitLab handles PKCE validation.
|
|
109
|
+
* - Callback proxy mode: we verify the client's PKCE manually in
|
|
110
|
+
* exchangeAuthorizationCode() after looking up stored tokens.
|
|
78
111
|
*/
|
|
79
112
|
skipLocalPkceValidation = true;
|
|
80
113
|
_gitlabBaseUrl;
|
|
81
114
|
_gitlabAppId;
|
|
82
115
|
_resourceName;
|
|
83
116
|
_requiredScopes;
|
|
84
|
-
_clientCache = new
|
|
85
|
-
|
|
117
|
+
_clientCache = new BoundedLRUMap(CLIENT_CACHE_MAX_SIZE);
|
|
118
|
+
// Callback proxy mode fields
|
|
119
|
+
_callbackProxyEnabled;
|
|
120
|
+
_callbackUrl;
|
|
121
|
+
_pendingAuth = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
122
|
+
_storedTokens = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
123
|
+
constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
|
|
86
124
|
this._gitlabBaseUrl = gitlabBaseUrl;
|
|
87
125
|
this._gitlabAppId = gitlabAppId;
|
|
88
126
|
this._resourceName = resourceName;
|
|
@@ -92,6 +130,14 @@ class GitLabOAuthServerProvider {
|
|
|
92
130
|
: readOnly
|
|
93
131
|
? REQUIRED_GITLAB_SCOPES_RO
|
|
94
132
|
: REQUIRED_GITLAB_SCOPES_RW;
|
|
133
|
+
this._callbackProxyEnabled = callbackProxyEnabled;
|
|
134
|
+
this._callbackUrl = callbackUrl;
|
|
135
|
+
if (callbackProxyEnabled && !callbackUrl) {
|
|
136
|
+
throw new Error("callbackUrl is required when callbackProxyEnabled is true");
|
|
137
|
+
}
|
|
138
|
+
if (callbackProxyEnabled) {
|
|
139
|
+
logger.info(`Callback proxy mode enabled — fixed callback URL: ${callbackUrl}`);
|
|
140
|
+
}
|
|
95
141
|
}
|
|
96
142
|
// ---- Client store (local DCR) ------------------------------------------
|
|
97
143
|
get clientsStore() {
|
|
@@ -130,7 +176,7 @@ class GitLabOAuthServerProvider {
|
|
|
130
176
|
};
|
|
131
177
|
}
|
|
132
178
|
// ---- Authorize ---------------------------------------------------------
|
|
133
|
-
async authorize(
|
|
179
|
+
async authorize(client, params, res) {
|
|
134
180
|
const scopes = params.scopes ?? [];
|
|
135
181
|
const hasRequired = this._requiredScopes.some((s) => scopes.includes(s));
|
|
136
182
|
const effectiveScopes = hasRequired
|
|
@@ -138,21 +184,57 @@ class GitLabOAuthServerProvider {
|
|
|
138
184
|
: [...new Set([...scopes, ...this._requiredScopes])];
|
|
139
185
|
// Build the GitLab authorize URL with the REAL app client_id
|
|
140
186
|
const targetUrl = new URL(`${this._gitlabBaseUrl}/oauth/authorize`);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
if (this._callbackProxyEnabled) {
|
|
188
|
+
// --- Callback proxy mode ---
|
|
189
|
+
// Generate a proxy PKCE pair (MCP server ↔ GitLab)
|
|
190
|
+
const proxyCodeVerifier = randomBytes(32).toString("base64url");
|
|
191
|
+
const proxyCodeChallenge = createHash("sha256")
|
|
192
|
+
.update(proxyCodeVerifier)
|
|
193
|
+
.digest("base64url");
|
|
194
|
+
// Use a unique state to correlate the callback
|
|
195
|
+
const proxyState = randomUUID();
|
|
196
|
+
// Store the client's original params so /callback can redirect back
|
|
197
|
+
this._pendingAuth.set(proxyState, {
|
|
198
|
+
clientId: client.client_id,
|
|
199
|
+
clientRedirectUri: params.redirectUri,
|
|
200
|
+
clientState: params.state,
|
|
201
|
+
clientCodeChallenge: params.codeChallenge,
|
|
202
|
+
proxyCodeVerifier,
|
|
203
|
+
createdAt: Date.now(),
|
|
204
|
+
});
|
|
205
|
+
const searchParams = new URLSearchParams({
|
|
206
|
+
client_id: this._gitlabAppId,
|
|
207
|
+
response_type: "code",
|
|
208
|
+
redirect_uri: this._callbackUrl,
|
|
209
|
+
code_challenge: proxyCodeChallenge,
|
|
210
|
+
code_challenge_method: "S256",
|
|
211
|
+
state: proxyState,
|
|
212
|
+
});
|
|
213
|
+
if (effectiveScopes.length)
|
|
214
|
+
searchParams.set("scope", effectiveScopes.join(" "));
|
|
215
|
+
if (params.resource)
|
|
216
|
+
searchParams.set("resource", params.resource.href);
|
|
217
|
+
targetUrl.search = searchParams.toString();
|
|
218
|
+
logger.info(`authorize (callback proxy): redirecting to GitLab with fixed callback URL (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
// --- Passthrough mode (original behavior) ---
|
|
222
|
+
const searchParams = new URLSearchParams({
|
|
223
|
+
client_id: this._gitlabAppId,
|
|
224
|
+
response_type: "code",
|
|
225
|
+
redirect_uri: params.redirectUri,
|
|
226
|
+
code_challenge: params.codeChallenge,
|
|
227
|
+
code_challenge_method: "S256",
|
|
228
|
+
});
|
|
229
|
+
if (params.state)
|
|
230
|
+
searchParams.set("state", params.state);
|
|
231
|
+
if (effectiveScopes.length)
|
|
232
|
+
searchParams.set("scope", effectiveScopes.join(" "));
|
|
233
|
+
if (params.resource)
|
|
234
|
+
searchParams.set("resource", params.resource.href);
|
|
235
|
+
targetUrl.search = searchParams.toString();
|
|
236
|
+
logger.info(`authorize: redirecting to GitLab (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
|
|
237
|
+
}
|
|
156
238
|
res.redirect(targetUrl.toString());
|
|
157
239
|
}
|
|
158
240
|
// ---- PKCE challenge (delegated to GitLab) ------------------------------
|
|
@@ -160,7 +242,45 @@ class GitLabOAuthServerProvider {
|
|
|
160
242
|
return "";
|
|
161
243
|
}
|
|
162
244
|
// ---- Token exchange ----------------------------------------------------
|
|
163
|
-
async exchangeAuthorizationCode(
|
|
245
|
+
async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
|
|
246
|
+
if (this._callbackProxyEnabled) {
|
|
247
|
+
// --- Callback proxy mode ---
|
|
248
|
+
// The authorizationCode is a proxy code we generated in handleCallback().
|
|
249
|
+
// Look up the stored tokens by proxy code.
|
|
250
|
+
const entry = this._storedTokens.get(authorizationCode);
|
|
251
|
+
if (!entry) {
|
|
252
|
+
throw new ServerError("Invalid or expired authorization code");
|
|
253
|
+
}
|
|
254
|
+
// Check TTL before consuming — expired entries can't be retried anyway,
|
|
255
|
+
// but we give a specific error so the client knows to restart the flow.
|
|
256
|
+
if (Date.now() - entry.createdAt > PENDING_AUTH_TTL_MS) {
|
|
257
|
+
this._storedTokens.delete(authorizationCode);
|
|
258
|
+
throw new ServerError("Authorization code expired — please restart the OAuth flow");
|
|
259
|
+
}
|
|
260
|
+
// One-time use: delete after validation
|
|
261
|
+
this._storedTokens.delete(authorizationCode);
|
|
262
|
+
// Bind the proxy code to the client and redirect_uri that initiated
|
|
263
|
+
// /authorize, preserving the normal OAuth authorization-code invariant.
|
|
264
|
+
if (client.client_id !== entry.clientId) {
|
|
265
|
+
throw new ServerError("Invalid client for authorization code");
|
|
266
|
+
}
|
|
267
|
+
if (redirectUri !== entry.clientRedirectUri) {
|
|
268
|
+
throw new ServerError("Invalid redirect_uri for authorization code");
|
|
269
|
+
}
|
|
270
|
+
// Verify client PKCE: the client's code_verifier must match the
|
|
271
|
+
// code_challenge stored during /authorize.
|
|
272
|
+
if (entry.clientCodeChallenge) {
|
|
273
|
+
if (!codeVerifier) {
|
|
274
|
+
throw new ServerError("PKCE code_verifier is required");
|
|
275
|
+
}
|
|
276
|
+
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
277
|
+
if (computed !== entry.clientCodeChallenge) {
|
|
278
|
+
throw new ServerError("PKCE verification failed");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return entry.tokens;
|
|
282
|
+
}
|
|
283
|
+
// --- Passthrough mode (original behavior) ---
|
|
164
284
|
const params = new URLSearchParams({
|
|
165
285
|
grant_type: "authorization_code",
|
|
166
286
|
client_id: this._gitlabAppId,
|
|
@@ -227,6 +347,86 @@ class GitLabOAuthServerProvider {
|
|
|
227
347
|
: undefined,
|
|
228
348
|
};
|
|
229
349
|
}
|
|
350
|
+
// ---- Callback handler (callback proxy mode) ----------------------------
|
|
351
|
+
/**
|
|
352
|
+
* Handle the OAuth callback from GitLab.
|
|
353
|
+
* Exchanges the auth code for tokens, stores them, generates a proxy code,
|
|
354
|
+
* and redirects to the MCP client's original callback URL.
|
|
355
|
+
*
|
|
356
|
+
* Mount this as GET /callback in the Express app.
|
|
357
|
+
*/
|
|
358
|
+
async handleCallback(req, res) {
|
|
359
|
+
if (!this._callbackProxyEnabled) {
|
|
360
|
+
res.status(404).send("Callback proxy mode is not enabled");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const code = req.query.code;
|
|
364
|
+
const state = req.query.state;
|
|
365
|
+
const error = req.query.error;
|
|
366
|
+
if (error) {
|
|
367
|
+
logger.error(`GitLab OAuth error: ${error} — ${req.query.error_description ?? "(no description)"}`);
|
|
368
|
+
res.status(400).send("Authorization failed");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!code || !state) {
|
|
372
|
+
res.status(400).send("Missing code or state parameter");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// Look up the pending auth transaction
|
|
376
|
+
const pending = this._pendingAuth.getAndDelete(state);
|
|
377
|
+
if (!pending) {
|
|
378
|
+
res.status(400).send("Unknown or expired state parameter");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Check TTL
|
|
382
|
+
if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) {
|
|
383
|
+
res.status(400).send("Authorization request expired");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Exchange the GitLab auth code for tokens using the proxy's PKCE verifier
|
|
387
|
+
try {
|
|
388
|
+
const tokenParams = new URLSearchParams({
|
|
389
|
+
grant_type: "authorization_code",
|
|
390
|
+
client_id: this._gitlabAppId,
|
|
391
|
+
code,
|
|
392
|
+
redirect_uri: this._callbackUrl,
|
|
393
|
+
code_verifier: pending.proxyCodeVerifier,
|
|
394
|
+
});
|
|
395
|
+
const tokenResponse = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
396
|
+
method: "POST",
|
|
397
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
398
|
+
body: tokenParams.toString(),
|
|
399
|
+
});
|
|
400
|
+
if (!tokenResponse.ok) {
|
|
401
|
+
const body = await tokenResponse.text();
|
|
402
|
+
logger.error(`Callback token exchange failed (${tokenResponse.status}): ${body}`);
|
|
403
|
+
res.status(502).send("Token exchange with GitLab failed");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const tokens = OAuthTokensSchema.parse(await tokenResponse.json());
|
|
407
|
+
// Generate a proxy auth code for the MCP client
|
|
408
|
+
const proxyCode = randomUUID();
|
|
409
|
+
this._storedTokens.set(proxyCode, {
|
|
410
|
+
tokens,
|
|
411
|
+
clientId: pending.clientId,
|
|
412
|
+
clientCodeChallenge: pending.clientCodeChallenge,
|
|
413
|
+
clientRedirectUri: pending.clientRedirectUri,
|
|
414
|
+
createdAt: Date.now(),
|
|
415
|
+
});
|
|
416
|
+
// Redirect to the MCP client's original callback URL
|
|
417
|
+
const clientCallback = new URL(pending.clientRedirectUri);
|
|
418
|
+
clientCallback.searchParams.set("code", proxyCode);
|
|
419
|
+
if (pending.clientState) {
|
|
420
|
+
clientCallback.searchParams.set("state", pending.clientState);
|
|
421
|
+
}
|
|
422
|
+
logger.info(`callback: exchanged code with GitLab, redirecting to client callback`);
|
|
423
|
+
res.redirect(clientCallback.toString());
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
logger.error({ err }, "Callback handler error");
|
|
427
|
+
res.status(500).send("Internal error during token exchange");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
230
430
|
// ---- Revoke token ------------------------------------------------------
|
|
231
431
|
async revokeToken(_client, request) {
|
|
232
432
|
const params = new URLSearchParams({
|
|
@@ -258,7 +458,11 @@ class GitLabOAuthServerProvider {
|
|
|
258
458
|
* @param resourceName Human-readable name shown on the GitLab consent screen.
|
|
259
459
|
* @param readOnly When true and customScopes is not set, restricts to read_api scope.
|
|
260
460
|
* @param customScopes Explicit list of GitLab scopes to require. Overrides readOnly when set.
|
|
461
|
+
* @param callbackProxyEnabled When true, the MCP server handles the OAuth callback internally.
|
|
462
|
+
* Only ONE fixed callback URL needs to be registered with GitLab.
|
|
463
|
+
* @param callbackUrl The fixed callback URL (e.g. https://mcp.example.com/callback).
|
|
464
|
+
* Required when callbackProxyEnabled is true.
|
|
261
465
|
*/
|
|
262
|
-
export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes) {
|
|
263
|
-
return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes);
|
|
466
|
+
export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
|
|
467
|
+
return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled, callbackUrl);
|
|
264
468
|
}
|
package/build/schemas.js
CHANGED
|
@@ -634,8 +634,14 @@ export const GetRepositoryTreeSchema = z.object({
|
|
|
634
634
|
.describe("The name of a repository branch or tag. Defaults to the default branch."),
|
|
635
635
|
recursive: z.coerce.boolean().optional().describe("Boolean value to get a recursive tree"),
|
|
636
636
|
per_page: z.coerce.number().optional().describe("Number of results to show per page"),
|
|
637
|
-
page_token: z
|
|
638
|
-
|
|
637
|
+
page_token: z
|
|
638
|
+
.string()
|
|
639
|
+
.optional()
|
|
640
|
+
.describe("Token for keyset pagination. Use the next_page_token value returned in the previous response to retrieve the next page."),
|
|
641
|
+
pagination: z
|
|
642
|
+
.string()
|
|
643
|
+
.optional()
|
|
644
|
+
.describe("Pagination method. Use 'keyset' for keyset-based pagination (required for repositories with many files). Non-keyset calls keep the legacy array response for backward compatibility; that legacy response shape is deprecated and may be removed in a future major release. Keyset calls return a structured response with items and next_page_token when more pages are available."),
|
|
639
645
|
});
|
|
640
646
|
export const GitLabTreeSchema = z.object({
|
|
641
647
|
id: z.string(), // Changed from sha to match GitLab API
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback Proxy Mode Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for GITLAB_OAUTH_CALLBACK_PROXY=true — the mode where the MCP server
|
|
5
|
+
* intercepts the OAuth callback from GitLab, exchanges the code for tokens
|
|
6
|
+
* server-side, and redirects to the MCP client with a proxy code.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, after, before } from "node:test";
|
|
9
|
+
import assert from "node:assert";
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
12
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
13
|
+
const MOCK_GITLAB_PORT_BASE = 9400;
|
|
14
|
+
const MCP_SERVER_PORT_BASE = 3400;
|
|
15
|
+
const MOCK_ACCESS_TOKEN = "ya29.mock-callback-proxy-token-123456";
|
|
16
|
+
const MOCK_REFRESH_TOKEN = "mock-refresh-token-abcdef";
|
|
17
|
+
const MOCK_APP_ID = "test-callback-proxy-app-id";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function generatePKCE() {
|
|
22
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
23
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
24
|
+
return { verifier, challenge };
|
|
25
|
+
}
|
|
26
|
+
/** Register a client via DCR and return the virtual client_id. */
|
|
27
|
+
async function registerClient(mcpBaseUrl, redirectUri) {
|
|
28
|
+
const res = await fetch(`${mcpBaseUrl}/register`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
redirect_uris: [redirectUri],
|
|
33
|
+
client_name: "callback-proxy-test",
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
assert.strictEqual(res.status, 201, `DCR failed: ${res.status}`);
|
|
37
|
+
const data = (await res.json());
|
|
38
|
+
return data.client_id;
|
|
39
|
+
}
|
|
40
|
+
/** Hit /authorize and return the redirect Location without following it. */
|
|
41
|
+
async function authorize(mcpBaseUrl, clientId, redirectUri, codeChallenge, state) {
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
response_type: "code",
|
|
44
|
+
client_id: clientId,
|
|
45
|
+
redirect_uri: redirectUri,
|
|
46
|
+
code_challenge: codeChallenge,
|
|
47
|
+
code_challenge_method: "S256",
|
|
48
|
+
state,
|
|
49
|
+
scope: "api",
|
|
50
|
+
});
|
|
51
|
+
const res = await fetch(`${mcpBaseUrl}/authorize?${params}`, {
|
|
52
|
+
redirect: "manual",
|
|
53
|
+
});
|
|
54
|
+
assert.strictEqual(res.status, 302, `Expected 302, got ${res.status}`);
|
|
55
|
+
const location = res.headers.get("location");
|
|
56
|
+
assert.ok(location, "Missing Location header");
|
|
57
|
+
return new URL(location);
|
|
58
|
+
}
|
|
59
|
+
/** Simulate GitLab redirecting to the MCP server's /callback. */
|
|
60
|
+
async function simulateGitLabCallback(mcpBaseUrl, code, state) {
|
|
61
|
+
const res = await fetch(`${mcpBaseUrl}/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, { redirect: "manual" });
|
|
62
|
+
return {
|
|
63
|
+
status: res.status,
|
|
64
|
+
location: res.headers.get("location"),
|
|
65
|
+
body: await res.text(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Exchange a proxy code for tokens via /token. */
|
|
69
|
+
async function exchangeToken(mcpBaseUrl, clientId, code, codeVerifier, redirectUri) {
|
|
70
|
+
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
73
|
+
body: new URLSearchParams({
|
|
74
|
+
grant_type: "authorization_code",
|
|
75
|
+
client_id: clientId,
|
|
76
|
+
code,
|
|
77
|
+
code_verifier: codeVerifier,
|
|
78
|
+
redirect_uri: redirectUri,
|
|
79
|
+
}).toString(),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
status: res.status,
|
|
83
|
+
body: (await res.json()),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Test suite
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
describe("Callback Proxy Mode", () => {
|
|
90
|
+
let mcpBaseUrl;
|
|
91
|
+
let mockGitLab;
|
|
92
|
+
let mockGitLabUrl;
|
|
93
|
+
let servers = [];
|
|
94
|
+
const clientRedirectUri = "http://localhost:19999/oauth/callback";
|
|
95
|
+
// Track the last code+verifier GitLab received so we can verify the proxy PKCE
|
|
96
|
+
let lastTokenRequest = {};
|
|
97
|
+
before(async () => {
|
|
98
|
+
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
|
|
99
|
+
mockGitLab = new MockGitLabServer({
|
|
100
|
+
port: mockPort,
|
|
101
|
+
validTokens: [MOCK_ACCESS_TOKEN],
|
|
102
|
+
});
|
|
103
|
+
await mockGitLab.start();
|
|
104
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
105
|
+
// Mock GitLab token exchange — returns tokens and records the request
|
|
106
|
+
mockGitLab.addRootHandler("post", "/oauth/token", (req, res) => {
|
|
107
|
+
// Body may be URL-encoded (from handleCallback) — parse manually if needed
|
|
108
|
+
let body = req.body ?? {};
|
|
109
|
+
if (typeof body === "string") {
|
|
110
|
+
body = Object.fromEntries(new URLSearchParams(body));
|
|
111
|
+
}
|
|
112
|
+
else if (!body.grant_type && req.headers["content-type"]?.includes("urlencoded")) {
|
|
113
|
+
// express.json() didn't parse it — collect raw body
|
|
114
|
+
let raw = "";
|
|
115
|
+
req.on("data", (chunk) => { raw += chunk.toString(); });
|
|
116
|
+
req.on("end", () => {
|
|
117
|
+
lastTokenRequest = Object.fromEntries(new URLSearchParams(raw));
|
|
118
|
+
res.json({
|
|
119
|
+
access_token: MOCK_ACCESS_TOKEN,
|
|
120
|
+
token_type: "Bearer",
|
|
121
|
+
expires_in: 7200,
|
|
122
|
+
refresh_token: MOCK_REFRESH_TOKEN,
|
|
123
|
+
scope: "api",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
lastTokenRequest = body;
|
|
129
|
+
res.json({
|
|
130
|
+
access_token: MOCK_ACCESS_TOKEN,
|
|
131
|
+
token_type: "Bearer",
|
|
132
|
+
expires_in: 7200,
|
|
133
|
+
refresh_token: MOCK_REFRESH_TOKEN,
|
|
134
|
+
scope: "api",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// Mock token introspection
|
|
138
|
+
mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => {
|
|
139
|
+
const auth = req.headers["authorization"];
|
|
140
|
+
const token = auth?.replace(/^Bearer\s+/i, "");
|
|
141
|
+
if (token !== MOCK_ACCESS_TOKEN) {
|
|
142
|
+
res.status(401).json({ error: "invalid_token" });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
res.json({
|
|
146
|
+
resource_owner_id: 42,
|
|
147
|
+
scopes: ["api"],
|
|
148
|
+
expires_in_seconds: 7200,
|
|
149
|
+
application: { uid: MOCK_APP_ID },
|
|
150
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
154
|
+
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
155
|
+
const server = await launchServer({
|
|
156
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
157
|
+
port: mcpPort,
|
|
158
|
+
timeout: 5000,
|
|
159
|
+
env: {
|
|
160
|
+
STREAMABLE_HTTP: "true",
|
|
161
|
+
GITLAB_MCP_OAUTH: "true",
|
|
162
|
+
GITLAB_OAUTH_CALLBACK_PROXY: "true",
|
|
163
|
+
GITLAB_OAUTH_APP_ID: MOCK_APP_ID,
|
|
164
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
165
|
+
MCP_SERVER_URL: mcpBaseUrl,
|
|
166
|
+
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
servers.push(server);
|
|
170
|
+
});
|
|
171
|
+
after(async () => {
|
|
172
|
+
cleanupServers(servers);
|
|
173
|
+
if (mockGitLab)
|
|
174
|
+
await mockGitLab.stop();
|
|
175
|
+
});
|
|
176
|
+
// ---- Happy path --------------------------------------------------------
|
|
177
|
+
test("full flow: authorize → callback → token exchange", async () => {
|
|
178
|
+
const clientPKCE = generatePKCE();
|
|
179
|
+
const clientState = "test-state-123";
|
|
180
|
+
// 1. Register client
|
|
181
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
182
|
+
// 2. Authorize — should redirect to GitLab with fixed callback URL
|
|
183
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, clientState);
|
|
184
|
+
assert.strictEqual(gitlabUrl.hostname, new URL(mockGitLabUrl).hostname);
|
|
185
|
+
assert.strictEqual(gitlabUrl.pathname, "/oauth/authorize");
|
|
186
|
+
// redirect_uri should be the MCP server's /callback, NOT the client's
|
|
187
|
+
assert.strictEqual(gitlabUrl.searchParams.get("redirect_uri"), `${mcpBaseUrl}/callback`);
|
|
188
|
+
// State should be a proxy state, NOT the client's original state
|
|
189
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
190
|
+
assert.ok(proxyState);
|
|
191
|
+
assert.notStrictEqual(proxyState, clientState);
|
|
192
|
+
// 3. Simulate GitLab callback to MCP server
|
|
193
|
+
const callbackResult = await simulateGitLabCallback(mcpBaseUrl, "gitlab-auth-code-xyz", proxyState);
|
|
194
|
+
assert.strictEqual(callbackResult.status, 302);
|
|
195
|
+
assert.ok(callbackResult.location);
|
|
196
|
+
const clientCallbackUrl = new URL(callbackResult.location);
|
|
197
|
+
// Should redirect to the CLIENT's callback URL
|
|
198
|
+
assert.ok(clientCallbackUrl.href.startsWith(clientRedirectUri));
|
|
199
|
+
// Should include a proxy code (not the GitLab code)
|
|
200
|
+
const proxyCode = clientCallbackUrl.searchParams.get("code");
|
|
201
|
+
assert.ok(proxyCode);
|
|
202
|
+
assert.notStrictEqual(proxyCode, "gitlab-auth-code-xyz");
|
|
203
|
+
// Should restore the client's original state
|
|
204
|
+
assert.strictEqual(clientCallbackUrl.searchParams.get("state"), clientState);
|
|
205
|
+
// 4. Exchange proxy code for tokens
|
|
206
|
+
const tokenResult = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
207
|
+
assert.strictEqual(tokenResult.status, 200);
|
|
208
|
+
assert.strictEqual(tokenResult.body.access_token, MOCK_ACCESS_TOKEN);
|
|
209
|
+
assert.strictEqual(tokenResult.body.refresh_token, MOCK_REFRESH_TOKEN);
|
|
210
|
+
});
|
|
211
|
+
// ---- One-time use proxy code -------------------------------------------
|
|
212
|
+
test("proxy code cannot be reused", async () => {
|
|
213
|
+
const clientPKCE = generatePKCE();
|
|
214
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
215
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-reuse");
|
|
216
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
217
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-reuse", proxyState);
|
|
218
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
219
|
+
// First exchange succeeds
|
|
220
|
+
const first = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
221
|
+
assert.strictEqual(first.status, 200);
|
|
222
|
+
// Second exchange with same code fails
|
|
223
|
+
const second = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
224
|
+
assert.notStrictEqual(second.status, 200);
|
|
225
|
+
});
|
|
226
|
+
// ---- Client binding -----------------------------------------------------
|
|
227
|
+
test("proxy code cannot be redeemed by a different client_id", async () => {
|
|
228
|
+
const clientPKCE = generatePKCE();
|
|
229
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
230
|
+
const otherClientId = await registerClient(mcpBaseUrl, "http://localhost:19998/oauth/callback");
|
|
231
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-client-binding");
|
|
232
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
233
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-client-binding", proxyState);
|
|
234
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
235
|
+
const result = await exchangeToken(mcpBaseUrl, otherClientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
236
|
+
assert.notStrictEqual(result.status, 200);
|
|
237
|
+
});
|
|
238
|
+
test("proxy code cannot be redeemed with a different redirect_uri", async () => {
|
|
239
|
+
const clientPKCE = generatePKCE();
|
|
240
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
241
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-redirect-binding");
|
|
242
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
243
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-redirect-binding", proxyState);
|
|
244
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
245
|
+
const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, "http://localhost:19998/oauth/callback");
|
|
246
|
+
assert.notStrictEqual(result.status, 200);
|
|
247
|
+
});
|
|
248
|
+
// ---- Unknown state parameter -------------------------------------------
|
|
249
|
+
test("callback with unknown state returns 400", async () => {
|
|
250
|
+
const result = await simulateGitLabCallback(mcpBaseUrl, "some-code", "unknown-state-value");
|
|
251
|
+
assert.strictEqual(result.status, 400);
|
|
252
|
+
assert.ok(result.body.includes("Unknown or expired"));
|
|
253
|
+
});
|
|
254
|
+
// ---- PKCE verification failure -----------------------------------------
|
|
255
|
+
test("token exchange with wrong code_verifier fails", async () => {
|
|
256
|
+
const clientPKCE = generatePKCE();
|
|
257
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
258
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-pkce");
|
|
259
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
260
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-pkce", proxyState);
|
|
261
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
262
|
+
// Exchange with WRONG verifier
|
|
263
|
+
const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, "wrong-verifier-value", clientRedirectUri);
|
|
264
|
+
assert.notStrictEqual(result.status, 200);
|
|
265
|
+
});
|
|
266
|
+
// ---- PKCE verifier omitted ---------------------------------------------
|
|
267
|
+
test("token exchange without code_verifier fails when challenge was stored", async () => {
|
|
268
|
+
const clientPKCE = generatePKCE();
|
|
269
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
270
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-no-verifier");
|
|
271
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
272
|
+
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-no-verifier", proxyState);
|
|
273
|
+
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
274
|
+
// Exchange WITHOUT verifier — should fail
|
|
275
|
+
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
278
|
+
body: new URLSearchParams({
|
|
279
|
+
grant_type: "authorization_code",
|
|
280
|
+
client_id: clientId,
|
|
281
|
+
code: proxyCode,
|
|
282
|
+
redirect_uri: clientRedirectUri,
|
|
283
|
+
}).toString(),
|
|
284
|
+
});
|
|
285
|
+
assert.notStrictEqual(res.status, 200);
|
|
286
|
+
});
|
|
287
|
+
// ---- Replayed state parameter ------------------------------------------
|
|
288
|
+
test("state cannot be replayed after callback", async () => {
|
|
289
|
+
const clientPKCE = generatePKCE();
|
|
290
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
291
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-replay");
|
|
292
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
293
|
+
// First callback succeeds
|
|
294
|
+
const first = await simulateGitLabCallback(mcpBaseUrl, "code-replay-1", proxyState);
|
|
295
|
+
assert.strictEqual(first.status, 302);
|
|
296
|
+
// Second callback with same state fails
|
|
297
|
+
const second = await simulateGitLabCallback(mcpBaseUrl, "code-replay-2", proxyState);
|
|
298
|
+
assert.strictEqual(second.status, 400);
|
|
299
|
+
});
|
|
300
|
+
// ---- Dual PKCE verification --------------------------------------------
|
|
301
|
+
test("proxy uses its own PKCE pair with GitLab", async () => {
|
|
302
|
+
const clientPKCE = generatePKCE();
|
|
303
|
+
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
304
|
+
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-dual-pkce");
|
|
305
|
+
// The code_challenge sent to GitLab should NOT be the client's challenge
|
|
306
|
+
const gitlabChallenge = gitlabUrl.searchParams.get("code_challenge");
|
|
307
|
+
assert.ok(gitlabChallenge);
|
|
308
|
+
assert.notStrictEqual(gitlabChallenge, clientPKCE.challenge);
|
|
309
|
+
// Complete the flow to verify the proxy verifier was sent to GitLab
|
|
310
|
+
const proxyState = gitlabUrl.searchParams.get("state");
|
|
311
|
+
await simulateGitLabCallback(mcpBaseUrl, "code-dual-pkce", proxyState);
|
|
312
|
+
// The token request to GitLab should have a code_verifier that matches
|
|
313
|
+
// the proxy challenge (not the client's verifier)
|
|
314
|
+
assert.ok(lastTokenRequest.code_verifier);
|
|
315
|
+
assert.notStrictEqual(lastTokenRequest.code_verifier, clientPKCE.verifier);
|
|
316
|
+
const computedChallenge = createHash("sha256")
|
|
317
|
+
.update(lastTokenRequest.code_verifier)
|
|
318
|
+
.digest("base64url");
|
|
319
|
+
assert.strictEqual(computedChallenge, gitlabChallenge);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -230,6 +230,55 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
|
|
|
230
230
|
await validateToolCalls(client, headerMockServer, MOCK_TOKEN_HEADER);
|
|
231
231
|
await client.disconnect();
|
|
232
232
|
});
|
|
233
|
+
test('should preserve legacy tree array response and return keyset metadata when requested', async () => {
|
|
234
|
+
const client = new CustomHeaderClient({
|
|
235
|
+
headers: {
|
|
236
|
+
'authorization': `Bearer ${MOCK_TOKEN_HEADER}`,
|
|
237
|
+
'X-GitLab-API-URL': `${headerMockServer.getUrl()}/api/v4`,
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
await client.connect(mcpUrl);
|
|
241
|
+
headerMockServer.clearCustomHandlers();
|
|
242
|
+
headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
|
|
243
|
+
assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
|
|
244
|
+
assert.strictEqual(req.query.pagination, undefined);
|
|
245
|
+
res.json([createMockTreeItem('legacy-blob')]);
|
|
246
|
+
});
|
|
247
|
+
const legacyResult = await client.callTool('get_repository_tree', { project_id: '4' });
|
|
248
|
+
const legacyContent = JSON.parse(legacyResult.content[0].text);
|
|
249
|
+
assert.ok(Array.isArray(legacyContent));
|
|
250
|
+
assert.strictEqual(legacyContent[0].id, 'legacy-blob');
|
|
251
|
+
headerMockServer.clearCustomHandlers();
|
|
252
|
+
headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
|
|
253
|
+
assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
|
|
254
|
+
assert.strictEqual(req.query.pagination, 'keyset');
|
|
255
|
+
res.set('x-next-page-token', 'token-blob');
|
|
256
|
+
res.json([createMockTreeItem('keyset-blob')]);
|
|
257
|
+
});
|
|
258
|
+
const keysetResult = await client.callTool('get_repository_tree', {
|
|
259
|
+
project_id: '4',
|
|
260
|
+
pagination: 'keyset',
|
|
261
|
+
});
|
|
262
|
+
const keysetContent = JSON.parse(keysetResult.content[0].text);
|
|
263
|
+
assert.ok(!Array.isArray(keysetContent));
|
|
264
|
+
assert.strictEqual(keysetContent.items[0].id, 'keyset-blob');
|
|
265
|
+
assert.strictEqual(keysetContent.next_page_token, 'token-blob');
|
|
266
|
+
headerMockServer.clearCustomHandlers();
|
|
267
|
+
headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
|
|
268
|
+
assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
|
|
269
|
+
assert.strictEqual(req.query.pagination, 'keyset');
|
|
270
|
+
res.set('x-next-page', 'fallback-token');
|
|
271
|
+
res.json([createMockTreeItem('fallback-blob')]);
|
|
272
|
+
});
|
|
273
|
+
const fallbackResult = await client.callTool('get_repository_tree', {
|
|
274
|
+
project_id: '4',
|
|
275
|
+
pagination: 'keyset',
|
|
276
|
+
});
|
|
277
|
+
const fallbackContent = JSON.parse(fallbackResult.content[0].text);
|
|
278
|
+
assert.strictEqual(fallbackContent.items[0].id, 'fallback-blob');
|
|
279
|
+
assert.strictEqual(fallbackContent.next_page_token, 'fallback-token');
|
|
280
|
+
await client.disconnect();
|
|
281
|
+
});
|
|
233
282
|
});
|
|
234
283
|
});
|
|
235
284
|
// Helper functions to create schema-compliant mock objects
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ts-node
|
|
2
|
-
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, GitLabTreeItemSchema } from '../schemas.js';
|
|
2
|
+
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, GitLabTreeItemSchema, GetRepositoryTreeSchema } from '../schemas.js';
|
|
3
3
|
function runGetFileContentsSchemaTests() {
|
|
4
4
|
console.log('🧪 Testing GetFileContentsSchema...');
|
|
5
5
|
const cases = [
|
|
@@ -625,6 +625,78 @@ function runGitLabTreeItemSchemaTests() {
|
|
|
625
625
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
626
626
|
return { passed, failed };
|
|
627
627
|
}
|
|
628
|
+
function runGetRepositoryTreeSchemaTests() {
|
|
629
|
+
console.log('\n=== GetRepositoryTree Schema Tests ===');
|
|
630
|
+
const cases = [
|
|
631
|
+
{
|
|
632
|
+
name: 'schema:get_repository_tree:minimal-project-id',
|
|
633
|
+
input: { project_id: 'my/project' },
|
|
634
|
+
expected: { project_id: 'my/project', path: undefined, ref: undefined, recursive: undefined, per_page: undefined, page_token: undefined, pagination: undefined },
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: 'schema:get_repository_tree:with-keyset-pagination',
|
|
638
|
+
input: { project_id: 'my/project', pagination: 'keyset', per_page: 100 },
|
|
639
|
+
expected: { project_id: 'my/project', pagination: 'keyset', per_page: 100 },
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
name: 'schema:get_repository_tree:page-token-for-next-page',
|
|
643
|
+
input: { project_id: 'my/project', pagination: 'keyset', per_page: 100, page_token: 'eyJpZCI6IjEyMyJ9' },
|
|
644
|
+
expected: { project_id: 'my/project', pagination: 'keyset', per_page: 100, page_token: 'eyJpZCI6IjEyMyJ9' },
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: 'schema:get_repository_tree:per-page-coerces-from-string',
|
|
648
|
+
input: { project_id: 'my/project', per_page: '50' },
|
|
649
|
+
expected: { project_id: 'my/project', per_page: 50 },
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
name: 'schema:get_repository_tree:recursive-coerces-from-string',
|
|
653
|
+
input: { project_id: 'my/project', recursive: 'true' },
|
|
654
|
+
expected: { project_id: 'my/project', recursive: true },
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: 'schema:get_repository_tree:with-path-and-ref',
|
|
658
|
+
input: { project_id: 'my/project', path: 'src/', ref: 'main' },
|
|
659
|
+
expected: { project_id: 'my/project', path: 'src/', ref: 'main' },
|
|
660
|
+
},
|
|
661
|
+
];
|
|
662
|
+
let passed = 0;
|
|
663
|
+
let failed = 0;
|
|
664
|
+
cases.forEach(testCase => {
|
|
665
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
666
|
+
const parsed = GetRepositoryTreeSchema.safeParse(testCase.input);
|
|
667
|
+
if (testCase.shouldFail) {
|
|
668
|
+
result.status = parsed.success ? 'failed' : 'passed';
|
|
669
|
+
if (parsed.success)
|
|
670
|
+
result.error = 'Expected schema validation to fail';
|
|
671
|
+
}
|
|
672
|
+
else if (parsed.success) {
|
|
673
|
+
const expected = testCase.expected || {};
|
|
674
|
+
const matches = Object.entries(expected).every(([key, value]) => {
|
|
675
|
+
const actual = parsed.data[key];
|
|
676
|
+
return JSON.stringify(actual) === JSON.stringify(value);
|
|
677
|
+
});
|
|
678
|
+
if (matches) {
|
|
679
|
+
result.status = 'passed';
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
687
|
+
}
|
|
688
|
+
if (result.status === 'passed') {
|
|
689
|
+
passed++;
|
|
690
|
+
console.log(`✅ ${result.name}`);
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
failed++;
|
|
694
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
698
|
+
return { passed, failed };
|
|
699
|
+
}
|
|
628
700
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
629
701
|
const getFileContentsResult = runGetFileContentsSchemaTests();
|
|
630
702
|
const fileContentResult = runGitLabFileContentSchemaTests();
|
|
@@ -634,8 +706,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
634
706
|
const repositorySchemaResult = runGitLabRepositorySchemaTests();
|
|
635
707
|
const labelsCoercionResult = runLabelsCoercionSchemaTests();
|
|
636
708
|
const treeItemResult = runGitLabTreeItemSchemaTests();
|
|
637
|
-
const
|
|
638
|
-
const
|
|
709
|
+
const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
|
|
710
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + treeItemResult.passed + repositoryTreeResult.passed;
|
|
711
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + treeItemResult.failed + repositoryTreeResult.failed;
|
|
639
712
|
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
640
713
|
if (totalFailed > 0) {
|
|
641
714
|
process.exit(1);
|
package/package.json
CHANGED