@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.
@@ -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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
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 = Math.random().toString(36).substring(7);
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 = Math.random().toString(36).substring(7);
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 = Math.random().toString(36).substring(7);
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