@zereight/mcp-gitlab 2.0.33 → 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 +233 -90
- package/build/gitlab-client-pool.js +114 -6
- package/build/index.js +2244 -98
- package/build/oauth-proxy.js +257 -0
- package/build/oauth.js +11 -6
- package/build/schemas.js +458 -199
- package/build/test/mcp-oauth-tests.js +443 -0
- package/build/test/multi-server-test.js +16 -8
- package/build/test/no-proxy-integration-test.js +183 -0
- package/build/test/no-proxy-test.js +138 -0
- package/build/test/remote-auth-simple-test.js +12 -1
- package/build/test/test-geteffectiveprojectid.js +245 -0
- 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/test-upload-markdown.js +148 -0
- package/build/test/utils/mock-gitlab-server.js +267 -163
- package/build/test/utils/server-launcher.js +45 -41
- package/package.json +3 -2
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Proxy — GitLab upstream
|
|
3
|
+
*
|
|
4
|
+
* Builds an OAuthServerProvider that handles the MCP spec OAuth flow while
|
|
5
|
+
* delegating actual authentication to a GitLab instance.
|
|
6
|
+
*
|
|
7
|
+
* ### Why not pure GitLab DCR?
|
|
8
|
+
*
|
|
9
|
+
* GitLab restricts dynamically registered (unverified) applications to the
|
|
10
|
+
* `mcp` scope, which is insufficient for API calls (need `api` or `read_api`).
|
|
11
|
+
* To work around this, the MCP server uses a **pre-registered GitLab OAuth
|
|
12
|
+
* application** (set via GITLAB_OAUTH_APP_ID env var) with the required scopes,
|
|
13
|
+
* and handles DCR locally — each MCP client gets a unique virtual client_id
|
|
14
|
+
* mapped to the real GitLab app.
|
|
15
|
+
*
|
|
16
|
+
* ### Flow
|
|
17
|
+
*
|
|
18
|
+
* 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally
|
|
19
|
+
* and returns a virtual client_id.
|
|
20
|
+
* 2. MCP client redirects to /authorize — proxy replaces the virtual client_id
|
|
21
|
+
* with the real GitLab app client_id and forwards to GitLab.
|
|
22
|
+
* 3. User authorizes on GitLab — redirect comes back with auth code.
|
|
23
|
+
* 4. MCP client calls POST /token — proxy exchanges the code with GitLab using
|
|
24
|
+
* the real client_id.
|
|
25
|
+
*
|
|
26
|
+
* Activated when GITLAB_MCP_OAUTH=true. All other auth modes are unaffected.
|
|
27
|
+
*/
|
|
28
|
+
import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
29
|
+
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
31
|
+
import { pino } from "pino";
|
|
32
|
+
const logger = pino({ name: "gitlab-mcp-oauth-proxy" });
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Bounded LRU client cache
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const CLIENT_CACHE_MAX_SIZE = 1000;
|
|
37
|
+
class BoundedClientCache {
|
|
38
|
+
_map = new Map();
|
|
39
|
+
_maxSize;
|
|
40
|
+
constructor(maxSize) {
|
|
41
|
+
this._maxSize = maxSize;
|
|
42
|
+
}
|
|
43
|
+
get(clientId) {
|
|
44
|
+
const entry = this._map.get(clientId);
|
|
45
|
+
if (entry) {
|
|
46
|
+
this._map.delete(clientId);
|
|
47
|
+
this._map.set(clientId, entry);
|
|
48
|
+
}
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
set(clientId, client) {
|
|
52
|
+
if (this._map.has(clientId)) {
|
|
53
|
+
this._map.delete(clientId);
|
|
54
|
+
}
|
|
55
|
+
else if (this._map.size >= this._maxSize) {
|
|
56
|
+
const lruKey = this._map.keys().next().value;
|
|
57
|
+
if (lruKey !== undefined)
|
|
58
|
+
this._map.delete(lruKey);
|
|
59
|
+
}
|
|
60
|
+
this._map.set(clientId, client);
|
|
61
|
+
}
|
|
62
|
+
get size() {
|
|
63
|
+
return this._map.size;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
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
|
+
class GitLabOAuthServerProvider {
|
|
76
|
+
/**
|
|
77
|
+
* Tell the SDK not to validate PKCE locally — GitLab handles it.
|
|
78
|
+
*/
|
|
79
|
+
skipLocalPkceValidation = true;
|
|
80
|
+
_gitlabBaseUrl;
|
|
81
|
+
_gitlabAppId;
|
|
82
|
+
_resourceName;
|
|
83
|
+
_requiredScopes;
|
|
84
|
+
_clientCache = new BoundedClientCache(CLIENT_CACHE_MAX_SIZE);
|
|
85
|
+
constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly) {
|
|
86
|
+
this._gitlabBaseUrl = gitlabBaseUrl;
|
|
87
|
+
this._gitlabAppId = gitlabAppId;
|
|
88
|
+
this._resourceName = resourceName;
|
|
89
|
+
this._requiredScopes = readOnly ? REQUIRED_GITLAB_SCOPES_RO : REQUIRED_GITLAB_SCOPES_RW;
|
|
90
|
+
}
|
|
91
|
+
// ---- Client store (local DCR) ------------------------------------------
|
|
92
|
+
get clientsStore() {
|
|
93
|
+
const cache = this._clientCache;
|
|
94
|
+
const resourceName = this._resourceName;
|
|
95
|
+
return {
|
|
96
|
+
getClient: async (clientId) => {
|
|
97
|
+
const cached = cache.get(clientId);
|
|
98
|
+
if (cached)
|
|
99
|
+
return cached;
|
|
100
|
+
// Unknown client — return a minimal stub so token exchange can proceed
|
|
101
|
+
// (GitLab is the ultimate validator).
|
|
102
|
+
return {
|
|
103
|
+
client_id: clientId,
|
|
104
|
+
redirect_uris: [],
|
|
105
|
+
token_endpoint_auth_method: "none",
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
registerClient: async (client) => {
|
|
109
|
+
// Generate a virtual client_id; all real OAuth operations use _gitlabAppId.
|
|
110
|
+
const virtualClientId = randomUUID();
|
|
111
|
+
const registered = {
|
|
112
|
+
client_id: virtualClientId,
|
|
113
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
114
|
+
redirect_uris: client.redirect_uris ?? [],
|
|
115
|
+
token_endpoint_auth_method: "none",
|
|
116
|
+
grant_types: client.grant_types ?? ["authorization_code"],
|
|
117
|
+
client_name: client.client_name
|
|
118
|
+
? `${client.client_name} via ${resourceName}`
|
|
119
|
+
: resourceName,
|
|
120
|
+
};
|
|
121
|
+
cache.set(virtualClientId, registered);
|
|
122
|
+
logger.info(`DCR: registered virtual client ${virtualClientId} (name: ${registered.client_name})`);
|
|
123
|
+
return registered;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// ---- Authorize ---------------------------------------------------------
|
|
128
|
+
async authorize(_client, params, res) {
|
|
129
|
+
const scopes = params.scopes ?? [];
|
|
130
|
+
const hasRequired = this._requiredScopes.some((s) => scopes.includes(s));
|
|
131
|
+
const effectiveScopes = hasRequired
|
|
132
|
+
? scopes
|
|
133
|
+
: [...new Set([...scopes, ...this._requiredScopes])];
|
|
134
|
+
// Build the GitLab authorize URL with the REAL app client_id
|
|
135
|
+
const targetUrl = new URL(`${this._gitlabBaseUrl}/oauth/authorize`);
|
|
136
|
+
const searchParams = new URLSearchParams({
|
|
137
|
+
client_id: this._gitlabAppId,
|
|
138
|
+
response_type: "code",
|
|
139
|
+
redirect_uri: params.redirectUri,
|
|
140
|
+
code_challenge: params.codeChallenge,
|
|
141
|
+
code_challenge_method: "S256",
|
|
142
|
+
});
|
|
143
|
+
if (params.state)
|
|
144
|
+
searchParams.set("state", params.state);
|
|
145
|
+
if (effectiveScopes.length)
|
|
146
|
+
searchParams.set("scope", effectiveScopes.join(" "));
|
|
147
|
+
if (params.resource)
|
|
148
|
+
searchParams.set("resource", params.resource.href);
|
|
149
|
+
targetUrl.search = searchParams.toString();
|
|
150
|
+
logger.info(`authorize: redirecting to GitLab (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
|
|
151
|
+
res.redirect(targetUrl.toString());
|
|
152
|
+
}
|
|
153
|
+
// ---- PKCE challenge (delegated to GitLab) ------------------------------
|
|
154
|
+
async challengeForAuthorizationCode(_client, _authorizationCode) {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
// ---- Token exchange ----------------------------------------------------
|
|
158
|
+
async exchangeAuthorizationCode(_client, authorizationCode, codeVerifier, redirectUri, resource) {
|
|
159
|
+
const params = new URLSearchParams({
|
|
160
|
+
grant_type: "authorization_code",
|
|
161
|
+
client_id: this._gitlabAppId,
|
|
162
|
+
code: authorizationCode,
|
|
163
|
+
});
|
|
164
|
+
if (codeVerifier)
|
|
165
|
+
params.append("code_verifier", codeVerifier);
|
|
166
|
+
if (redirectUri)
|
|
167
|
+
params.append("redirect_uri", redirectUri);
|
|
168
|
+
if (resource)
|
|
169
|
+
params.append("resource", resource.href);
|
|
170
|
+
const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
173
|
+
body: params.toString(),
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
const body = await response.text();
|
|
177
|
+
logger.error(`Token exchange failed (${response.status}): ${body}`);
|
|
178
|
+
throw new ServerError(`Token exchange failed: ${response.status}`);
|
|
179
|
+
}
|
|
180
|
+
const data = await response.json();
|
|
181
|
+
return OAuthTokensSchema.parse(data);
|
|
182
|
+
}
|
|
183
|
+
// ---- Refresh token -----------------------------------------------------
|
|
184
|
+
async exchangeRefreshToken(_client, refreshToken, scopes, resource) {
|
|
185
|
+
const params = new URLSearchParams({
|
|
186
|
+
grant_type: "refresh_token",
|
|
187
|
+
client_id: this._gitlabAppId,
|
|
188
|
+
refresh_token: refreshToken,
|
|
189
|
+
});
|
|
190
|
+
if (scopes?.length)
|
|
191
|
+
params.set("scope", scopes.join(" "));
|
|
192
|
+
if (resource)
|
|
193
|
+
params.set("resource", resource.href);
|
|
194
|
+
const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
197
|
+
body: params.toString(),
|
|
198
|
+
});
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
const body = await response.text();
|
|
201
|
+
logger.error(`Token refresh failed (${response.status}): ${body}`);
|
|
202
|
+
throw new ServerError(`Token refresh failed: ${response.status}`);
|
|
203
|
+
}
|
|
204
|
+
const data = await response.json();
|
|
205
|
+
return OAuthTokensSchema.parse(data);
|
|
206
|
+
}
|
|
207
|
+
// ---- Verify access token -----------------------------------------------
|
|
208
|
+
async verifyAccessToken(token) {
|
|
209
|
+
const res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, {
|
|
210
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
211
|
+
});
|
|
212
|
+
if (!res.ok) {
|
|
213
|
+
throw new InvalidTokenError("Invalid or expired GitLab OAuth token");
|
|
214
|
+
}
|
|
215
|
+
const info = (await res.json());
|
|
216
|
+
return {
|
|
217
|
+
token,
|
|
218
|
+
clientId: info.application?.uid ?? "dynamic",
|
|
219
|
+
scopes: info.scopes ?? [],
|
|
220
|
+
expiresAt: info.expires_in_seconds != null
|
|
221
|
+
? Math.floor(Date.now() / 1000) + info.expires_in_seconds
|
|
222
|
+
: undefined,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// ---- Revoke token ------------------------------------------------------
|
|
226
|
+
async revokeToken(_client, request) {
|
|
227
|
+
const params = new URLSearchParams({
|
|
228
|
+
token: request.token,
|
|
229
|
+
client_id: this._gitlabAppId,
|
|
230
|
+
});
|
|
231
|
+
if (request.token_type_hint) {
|
|
232
|
+
params.set("token_type_hint", request.token_type_hint);
|
|
233
|
+
}
|
|
234
|
+
const response = await fetch(`${this._gitlabBaseUrl}/oauth/revoke`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
237
|
+
body: params.toString(),
|
|
238
|
+
});
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
throw new ServerError(`Token revocation failed: ${response.status}`);
|
|
241
|
+
}
|
|
242
|
+
await response.body?.cancel();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Factory
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
/**
|
|
249
|
+
* Build a GitLabOAuthServerProvider for the given GitLab instance.
|
|
250
|
+
*
|
|
251
|
+
* @param gitlabBaseUrl Root URL of the GitLab instance (no trailing slash, no /api/v4).
|
|
252
|
+
* @param gitlabAppId Client ID of the pre-registered GitLab OAuth application.
|
|
253
|
+
* @param resourceName Human-readable name shown on the GitLab consent screen.
|
|
254
|
+
*/
|
|
255
|
+
export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false) {
|
|
256
|
+
return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly);
|
|
257
|
+
}
|
package/build/oauth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
1
2
|
import * as fs from "fs";
|
|
2
3
|
import * as os from "os";
|
|
3
4
|
import * as path from "path";
|
|
@@ -10,7 +11,11 @@ import { pino } from "pino";
|
|
|
10
11
|
const logger = pino({
|
|
11
12
|
name: "gitlab-mcp-oauth",
|
|
12
13
|
level: process.env.LOG_LEVEL || "info",
|
|
13
|
-
});
|
|
14
|
+
}, pino.destination(2));
|
|
15
|
+
function escapeHtml(str) {
|
|
16
|
+
const map = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
17
|
+
return String(str).replace(/[&<>"']/g, c => map[c] || c);
|
|
18
|
+
}
|
|
14
19
|
// Track pending auth requests across multiple MCP instances
|
|
15
20
|
const pendingAuthRequests = new Map();
|
|
16
21
|
/**
|
|
@@ -225,7 +230,7 @@ export class GitLabOAuth {
|
|
|
225
230
|
*/
|
|
226
231
|
async startOAuthFlow() {
|
|
227
232
|
const callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888");
|
|
228
|
-
const requestId =
|
|
233
|
+
const requestId = crypto.randomUUID();
|
|
229
234
|
// Check if port is already in use
|
|
230
235
|
const portInUse = await isPortInUse(callbackPort);
|
|
231
236
|
if (portInUse) {
|
|
@@ -250,7 +255,7 @@ export class GitLabOAuth {
|
|
|
250
255
|
const requestIdToOAuthInstance = new Map();
|
|
251
256
|
return new Promise((resolve, reject) => {
|
|
252
257
|
// Create initial request
|
|
253
|
-
const state =
|
|
258
|
+
const state = crypto.randomUUID();
|
|
254
259
|
stateToRequestId.set(state, initialRequestId);
|
|
255
260
|
requestIdToOAuthInstance.set(initialRequestId, this);
|
|
256
261
|
const timeout = setTimeout(() => {
|
|
@@ -271,7 +276,7 @@ export class GitLabOAuth {
|
|
|
271
276
|
}
|
|
272
277
|
logger.info(`Received auth request from another instance: ${newRequestId}`);
|
|
273
278
|
// Create a new OAuth flow for this request
|
|
274
|
-
const newState =
|
|
279
|
+
const newState = crypto.randomUUID();
|
|
275
280
|
stateToRequestId.set(newState, newRequestId);
|
|
276
281
|
// Store a reference to use the same OAuth config
|
|
277
282
|
requestIdToOAuthInstance.set(newRequestId, this);
|
|
@@ -315,7 +320,7 @@ export class GitLabOAuth {
|
|
|
315
320
|
<html>
|
|
316
321
|
<body>
|
|
317
322
|
<h1>Authentication Failed</h1>
|
|
318
|
-
<p>Error: ${error}</p>
|
|
323
|
+
<p>Error: ${escapeHtml(String(error))}</p>
|
|
319
324
|
<p>You can close this window.</p>
|
|
320
325
|
</body>
|
|
321
326
|
</html>
|
|
@@ -523,7 +528,7 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
|
|
|
523
528
|
clientSecret,
|
|
524
529
|
redirectUri,
|
|
525
530
|
gitlabUrl,
|
|
526
|
-
scopes: ["api"],
|
|
531
|
+
scopes: [process.env.GITLAB_READ_ONLY_MODE === "true" ? "read_api" : "api"],
|
|
527
532
|
tokenStoragePath,
|
|
528
533
|
});
|
|
529
534
|
// Single call: triggers browser flow if needed, or reads cached token
|