@zereight/mcp-gitlab 2.0.8 → 2.0.11
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 +80 -3
- package/build/index.js +233 -27
- package/build/oauth.js +518 -0
- package/build/schemas.js +39 -4
- package/build/test/oauth-tests.js +389 -0
- package/package.json +5 -2
package/build/oauth.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as http from "http";
|
|
4
|
+
import * as net from "net";
|
|
5
|
+
import * as url from "url";
|
|
6
|
+
import open from "open";
|
|
7
|
+
import pkceChallenge from "pkce-challenge";
|
|
8
|
+
import { pino } from "pino";
|
|
9
|
+
const logger = pino({
|
|
10
|
+
name: "gitlab-mcp-oauth",
|
|
11
|
+
level: process.env.LOG_LEVEL || "info",
|
|
12
|
+
});
|
|
13
|
+
// Track pending auth requests across multiple MCP instances
|
|
14
|
+
const pendingAuthRequests = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* Check if a port is already in use
|
|
17
|
+
*/
|
|
18
|
+
async function isPortInUse(port) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const server = net.createServer();
|
|
21
|
+
server.once("error", (err) => {
|
|
22
|
+
if (err.code === "EADDRINUSE") {
|
|
23
|
+
resolve(true);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
resolve(false);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
server.once("listening", () => {
|
|
30
|
+
server.close();
|
|
31
|
+
resolve(false);
|
|
32
|
+
});
|
|
33
|
+
server.listen(port, "127.0.0.1");
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Request authentication from an existing OAuth server
|
|
38
|
+
*/
|
|
39
|
+
async function requestAuthFromExistingServer(port, requestId) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const options = {
|
|
42
|
+
hostname: "127.0.0.1",
|
|
43
|
+
port: port,
|
|
44
|
+
path: `/auth-request?requestId=${requestId}`,
|
|
45
|
+
method: "GET",
|
|
46
|
+
};
|
|
47
|
+
const req = http.request(options, (res) => {
|
|
48
|
+
let data = "";
|
|
49
|
+
res.on("data", (chunk) => {
|
|
50
|
+
data += chunk;
|
|
51
|
+
});
|
|
52
|
+
res.on("end", () => {
|
|
53
|
+
if (res.statusCode === 200) {
|
|
54
|
+
try {
|
|
55
|
+
const tokenData = JSON.parse(data);
|
|
56
|
+
resolve(tokenData);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
reject(new Error(`Failed to parse token data: ${error}`));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
reject(new Error(`Auth request failed with status ${res.statusCode}: ${data}`));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
req.on("error", (error) => {
|
|
68
|
+
reject(new Error(`Failed to connect to existing OAuth server: ${error.message}`));
|
|
69
|
+
});
|
|
70
|
+
req.setTimeout(5 * 60 * 1000, () => {
|
|
71
|
+
req.destroy();
|
|
72
|
+
reject(new Error("Auth request timed out"));
|
|
73
|
+
});
|
|
74
|
+
req.end();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export class GitLabOAuth {
|
|
78
|
+
config;
|
|
79
|
+
tokenStoragePath;
|
|
80
|
+
codeVerifier;
|
|
81
|
+
codeChallenge;
|
|
82
|
+
constructor(config) {
|
|
83
|
+
this.config = config;
|
|
84
|
+
this.tokenStoragePath =
|
|
85
|
+
config.tokenStoragePath ||
|
|
86
|
+
path.join(process.env.HOME || "", ".gitlab-mcp-token.json");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Get the authorization URL for OAuth flow
|
|
90
|
+
*/
|
|
91
|
+
async getAuthorizationUrl(state) {
|
|
92
|
+
const challenge = await pkceChallenge();
|
|
93
|
+
this.codeVerifier = challenge.code_verifier;
|
|
94
|
+
this.codeChallenge = challenge.code_challenge;
|
|
95
|
+
const params = new URLSearchParams();
|
|
96
|
+
params.append("client_id", this.config.clientId);
|
|
97
|
+
params.append("redirect_uri", this.config.redirectUri);
|
|
98
|
+
params.append("response_type", "code");
|
|
99
|
+
params.append("state", state);
|
|
100
|
+
params.append("scope", this.config.scopes.join(" "));
|
|
101
|
+
if (this.codeChallenge) {
|
|
102
|
+
params.append("code_challenge", this.codeChallenge);
|
|
103
|
+
params.append("code_challenge_method", "S256");
|
|
104
|
+
}
|
|
105
|
+
return `${this.config.gitlabUrl}/oauth/authorize?${params.toString()}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Exchange authorization code for access token
|
|
109
|
+
*/
|
|
110
|
+
async exchangeCodeForToken(code) {
|
|
111
|
+
if (!this.codeVerifier) {
|
|
112
|
+
throw new Error("Code verifier not found. Authorization flow not started.");
|
|
113
|
+
}
|
|
114
|
+
const tokenUrl = `${this.config.gitlabUrl}/oauth/token`;
|
|
115
|
+
const params = new URLSearchParams({
|
|
116
|
+
client_id: this.config.clientId,
|
|
117
|
+
code: code,
|
|
118
|
+
grant_type: "authorization_code",
|
|
119
|
+
redirect_uri: this.config.redirectUri,
|
|
120
|
+
code_verifier: this.codeVerifier,
|
|
121
|
+
});
|
|
122
|
+
const response = await fetch(tokenUrl, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
126
|
+
},
|
|
127
|
+
body: params.toString(),
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
const errorText = await response.text();
|
|
131
|
+
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
|
132
|
+
}
|
|
133
|
+
const data = await response.json();
|
|
134
|
+
return {
|
|
135
|
+
access_token: data.access_token,
|
|
136
|
+
refresh_token: data.refresh_token,
|
|
137
|
+
expires_in: data.expires_in,
|
|
138
|
+
created_at: Date.now(),
|
|
139
|
+
token_type: data.token_type,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Refresh the access token using the refresh token
|
|
144
|
+
*/
|
|
145
|
+
async refreshAccessToken(refreshToken) {
|
|
146
|
+
const tokenUrl = `${this.config.gitlabUrl}/oauth/token`;
|
|
147
|
+
const params = new URLSearchParams({
|
|
148
|
+
client_id: this.config.clientId,
|
|
149
|
+
refresh_token: refreshToken,
|
|
150
|
+
grant_type: "refresh_token",
|
|
151
|
+
redirect_uri: this.config.redirectUri,
|
|
152
|
+
});
|
|
153
|
+
const response = await fetch(tokenUrl, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
157
|
+
},
|
|
158
|
+
body: params.toString(),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const errorText = await response.text();
|
|
162
|
+
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
|
|
163
|
+
}
|
|
164
|
+
const data = await response.json();
|
|
165
|
+
return {
|
|
166
|
+
access_token: data.access_token,
|
|
167
|
+
refresh_token: data.refresh_token || refreshToken,
|
|
168
|
+
expires_in: data.expires_in,
|
|
169
|
+
created_at: Date.now(),
|
|
170
|
+
token_type: data.token_type,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Save token data to storage
|
|
175
|
+
*/
|
|
176
|
+
saveToken(tokenData) {
|
|
177
|
+
try {
|
|
178
|
+
fs.writeFileSync(this.tokenStoragePath, JSON.stringify(tokenData, null, 2), { mode: 0o600 } // Restrict access to owner only
|
|
179
|
+
);
|
|
180
|
+
logger.info(`Token saved to ${this.tokenStoragePath}`);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
logger.error("Failed to save token:", error);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Load token data from storage
|
|
189
|
+
*/
|
|
190
|
+
loadToken() {
|
|
191
|
+
try {
|
|
192
|
+
if (!fs.existsSync(this.tokenStoragePath)) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const data = fs.readFileSync(this.tokenStoragePath, "utf8");
|
|
196
|
+
return JSON.parse(data);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
logger.error("Failed to load token:", error);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Check if the token is expired
|
|
205
|
+
*/
|
|
206
|
+
isTokenExpired(tokenData) {
|
|
207
|
+
if (!tokenData.expires_in) {
|
|
208
|
+
return false; // If no expiry, assume it's still valid
|
|
209
|
+
}
|
|
210
|
+
const expiryTime = tokenData.created_at + tokenData.expires_in * 1000;
|
|
211
|
+
// Add 5 minute buffer to refresh before actual expiry
|
|
212
|
+
return Date.now() >= expiryTime - 5 * 60 * 1000;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Start OAuth flow and wait for callback
|
|
216
|
+
* Uses a shared server if port is already in use
|
|
217
|
+
*/
|
|
218
|
+
async startOAuthFlow() {
|
|
219
|
+
const callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888");
|
|
220
|
+
const requestId = Math.random().toString(36).substring(7);
|
|
221
|
+
// Check if port is already in use
|
|
222
|
+
const portInUse = await isPortInUse(callbackPort);
|
|
223
|
+
if (portInUse) {
|
|
224
|
+
// Port is in use, try to connect to existing server
|
|
225
|
+
logger.info(`Port ${callbackPort} is already in use. Connecting to existing OAuth server...`);
|
|
226
|
+
try {
|
|
227
|
+
return await requestAuthFromExistingServer(callbackPort, requestId);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
logger.error("Failed to connect to existing OAuth server:", error);
|
|
231
|
+
throw new Error(`Port ${callbackPort} is in use but cannot connect to existing OAuth server. Please close other instances or use a different port.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Port is free, start the shared OAuth server
|
|
235
|
+
return this.startSharedOAuthServer(callbackPort, requestId);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Start a shared OAuth server that can handle multiple authentication requests
|
|
239
|
+
*/
|
|
240
|
+
async startSharedOAuthServer(callbackPort, initialRequestId) {
|
|
241
|
+
const stateToRequestId = new Map();
|
|
242
|
+
const requestIdToOAuthInstance = new Map();
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
// Create initial request
|
|
245
|
+
const state = Math.random().toString(36).substring(7);
|
|
246
|
+
stateToRequestId.set(state, initialRequestId);
|
|
247
|
+
requestIdToOAuthInstance.set(initialRequestId, this);
|
|
248
|
+
const timeout = setTimeout(() => {
|
|
249
|
+
pendingAuthRequests.get(initialRequestId)?.reject(new Error("OAuth flow timed out"));
|
|
250
|
+
pendingAuthRequests.delete(initialRequestId);
|
|
251
|
+
}, 5 * 60 * 1000);
|
|
252
|
+
pendingAuthRequests.set(initialRequestId, { resolve, reject, timeout });
|
|
253
|
+
const server = http.createServer(async (req, res) => {
|
|
254
|
+
try {
|
|
255
|
+
const parsedUrl = url.parse(req.url || "", true);
|
|
256
|
+
// Handle auth requests from other MCP instances
|
|
257
|
+
if (parsedUrl.pathname === "/auth-request") {
|
|
258
|
+
const newRequestId = parsedUrl.query.requestId;
|
|
259
|
+
if (!newRequestId) {
|
|
260
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
261
|
+
res.end("Missing requestId parameter");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
logger.info(`Received auth request from another instance: ${newRequestId}`);
|
|
265
|
+
// Create a new OAuth flow for this request
|
|
266
|
+
const newState = Math.random().toString(36).substring(7);
|
|
267
|
+
stateToRequestId.set(newState, newRequestId);
|
|
268
|
+
// Store a reference to use the same OAuth config
|
|
269
|
+
requestIdToOAuthInstance.set(newRequestId, this);
|
|
270
|
+
// Open browser for this new request
|
|
271
|
+
const authUrl = await this.getAuthorizationUrl(newState);
|
|
272
|
+
logger.info("Opening browser for new authentication request...");
|
|
273
|
+
logger.info(`If browser doesn't open, visit: ${authUrl}`);
|
|
274
|
+
open(authUrl).catch((err) => {
|
|
275
|
+
logger.error("Failed to open browser:", err);
|
|
276
|
+
logger.info(`Please manually open: ${authUrl}`);
|
|
277
|
+
});
|
|
278
|
+
// Wait for the auth to complete
|
|
279
|
+
const authPromise = new Promise((authResolve, authReject) => {
|
|
280
|
+
const authTimeout = setTimeout(() => {
|
|
281
|
+
authReject(new Error("OAuth flow timed out"));
|
|
282
|
+
pendingAuthRequests.delete(newRequestId);
|
|
283
|
+
}, 5 * 60 * 1000);
|
|
284
|
+
pendingAuthRequests.set(newRequestId, {
|
|
285
|
+
resolve: authResolve,
|
|
286
|
+
reject: authReject,
|
|
287
|
+
timeout: authTimeout,
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
const tokenData = await authPromise;
|
|
292
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
293
|
+
res.end(JSON.stringify(tokenData));
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
297
|
+
res.end(`Authentication failed: ${error}`);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Handle OAuth callback
|
|
302
|
+
if (parsedUrl.pathname === "/callback") {
|
|
303
|
+
const { code, state: returnedState, error } = parsedUrl.query;
|
|
304
|
+
if (error) {
|
|
305
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
306
|
+
res.end(`
|
|
307
|
+
<html>
|
|
308
|
+
<body>
|
|
309
|
+
<h1>Authentication Failed</h1>
|
|
310
|
+
<p>Error: ${error}</p>
|
|
311
|
+
<p>You can close this window.</p>
|
|
312
|
+
</body>
|
|
313
|
+
</html>
|
|
314
|
+
`);
|
|
315
|
+
// Find and reject the corresponding request
|
|
316
|
+
const reqId = stateToRequestId.get(returnedState);
|
|
317
|
+
if (reqId) {
|
|
318
|
+
const pending = pendingAuthRequests.get(reqId);
|
|
319
|
+
if (pending) {
|
|
320
|
+
clearTimeout(pending.timeout);
|
|
321
|
+
pending.reject(new Error(`OAuth error: ${error}`));
|
|
322
|
+
pendingAuthRequests.delete(reqId);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!returnedState || typeof returnedState !== "string") {
|
|
328
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
329
|
+
res.end(`
|
|
330
|
+
<html>
|
|
331
|
+
<body>
|
|
332
|
+
<h1>Authentication Failed</h1>
|
|
333
|
+
<p>Invalid state parameter</p>
|
|
334
|
+
<p>You can close this window.</p>
|
|
335
|
+
</body>
|
|
336
|
+
</html>
|
|
337
|
+
`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const reqId = stateToRequestId.get(returnedState);
|
|
341
|
+
if (!reqId) {
|
|
342
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
343
|
+
res.end(`
|
|
344
|
+
<html>
|
|
345
|
+
<body>
|
|
346
|
+
<h1>Authentication Failed</h1>
|
|
347
|
+
<p>Unknown state parameter</p>
|
|
348
|
+
<p>You can close this window.</p>
|
|
349
|
+
</body>
|
|
350
|
+
</html>
|
|
351
|
+
`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!code || typeof code !== "string") {
|
|
355
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
356
|
+
res.end(`
|
|
357
|
+
<html>
|
|
358
|
+
<body>
|
|
359
|
+
<h1>Authentication Failed</h1>
|
|
360
|
+
<p>No authorization code received</p>
|
|
361
|
+
<p>You can close this window.</p>
|
|
362
|
+
</body>
|
|
363
|
+
</html>
|
|
364
|
+
`);
|
|
365
|
+
const pending = pendingAuthRequests.get(reqId);
|
|
366
|
+
if (pending) {
|
|
367
|
+
clearTimeout(pending.timeout);
|
|
368
|
+
pending.reject(new Error("No authorization code received"));
|
|
369
|
+
pendingAuthRequests.delete(reqId);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const oauthInstance = requestIdToOAuthInstance.get(reqId) || this;
|
|
375
|
+
const tokenData = await oauthInstance.exchangeCodeForToken(code);
|
|
376
|
+
oauthInstance.saveToken(tokenData);
|
|
377
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
378
|
+
res.end(`
|
|
379
|
+
<html>
|
|
380
|
+
<body>
|
|
381
|
+
<h1>Authentication Successful!</h1>
|
|
382
|
+
<p>You can close this window and return to the terminal.</p>
|
|
383
|
+
</body>
|
|
384
|
+
</html>
|
|
385
|
+
`);
|
|
386
|
+
const pending = pendingAuthRequests.get(reqId);
|
|
387
|
+
if (pending) {
|
|
388
|
+
clearTimeout(pending.timeout);
|
|
389
|
+
pending.resolve(tokenData);
|
|
390
|
+
pendingAuthRequests.delete(reqId);
|
|
391
|
+
}
|
|
392
|
+
stateToRequestId.delete(returnedState);
|
|
393
|
+
requestIdToOAuthInstance.delete(reqId);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
397
|
+
res.end(`
|
|
398
|
+
<html>
|
|
399
|
+
<body>
|
|
400
|
+
<h1>Authentication Failed</h1>
|
|
401
|
+
<p>Failed to exchange code for token</p>
|
|
402
|
+
<p>You can close this window.</p>
|
|
403
|
+
</body>
|
|
404
|
+
</html>
|
|
405
|
+
`);
|
|
406
|
+
const pending = pendingAuthRequests.get(reqId);
|
|
407
|
+
if (pending) {
|
|
408
|
+
clearTimeout(pending.timeout);
|
|
409
|
+
pending.reject(error);
|
|
410
|
+
pendingAuthRequests.delete(reqId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
416
|
+
res.end("Not Found");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
logger.error("Error handling request:", error);
|
|
421
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
422
|
+
res.end("Internal Server Error");
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
server.listen(callbackPort, "127.0.0.1", async () => {
|
|
426
|
+
logger.info(`Shared OAuth callback server listening on port ${callbackPort}`);
|
|
427
|
+
const authUrl = await this.getAuthorizationUrl(state);
|
|
428
|
+
logger.info("Opening browser for authentication...");
|
|
429
|
+
logger.info(`If browser doesn't open, visit: ${authUrl}`);
|
|
430
|
+
open(authUrl).catch((err) => {
|
|
431
|
+
logger.error("Failed to open browser:", err);
|
|
432
|
+
logger.info(`Please manually open: ${authUrl}`);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
server.on("error", (error) => {
|
|
436
|
+
logger.error("OAuth server error:", error);
|
|
437
|
+
const pending = pendingAuthRequests.get(initialRequestId);
|
|
438
|
+
if (pending) {
|
|
439
|
+
clearTimeout(pending.timeout);
|
|
440
|
+
pending.reject(error);
|
|
441
|
+
pendingAuthRequests.delete(initialRequestId);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Get a valid access token, refreshing if necessary
|
|
448
|
+
*/
|
|
449
|
+
async getAccessToken() {
|
|
450
|
+
let tokenData = this.loadToken();
|
|
451
|
+
// If no token or expired, start OAuth flow or refresh
|
|
452
|
+
if (!tokenData) {
|
|
453
|
+
logger.info("No stored token found. Starting OAuth flow...");
|
|
454
|
+
tokenData = await this.startOAuthFlow();
|
|
455
|
+
}
|
|
456
|
+
else if (this.isTokenExpired(tokenData)) {
|
|
457
|
+
logger.info("Token expired. Refreshing...");
|
|
458
|
+
if (tokenData.refresh_token) {
|
|
459
|
+
try {
|
|
460
|
+
tokenData = await this.refreshAccessToken(tokenData.refresh_token);
|
|
461
|
+
this.saveToken(tokenData);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
logger.error("Token refresh failed. Starting new OAuth flow...", error);
|
|
465
|
+
tokenData = await this.startOAuthFlow();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
logger.info("No refresh token available. Starting new OAuth flow...");
|
|
470
|
+
tokenData = await this.startOAuthFlow();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return tokenData.access_token;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Clear stored token
|
|
477
|
+
*/
|
|
478
|
+
clearToken() {
|
|
479
|
+
try {
|
|
480
|
+
if (fs.existsSync(this.tokenStoragePath)) {
|
|
481
|
+
fs.unlinkSync(this.tokenStoragePath);
|
|
482
|
+
logger.info("Token cleared");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
logger.error("Failed to clear token:", error);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Check if a valid token exists
|
|
491
|
+
*/
|
|
492
|
+
hasValidToken() {
|
|
493
|
+
const tokenData = this.loadToken();
|
|
494
|
+
if (!tokenData) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
return !this.isTokenExpired(tokenData);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Initialize OAuth authentication for GitLab MCP server
|
|
502
|
+
*/
|
|
503
|
+
export async function initializeOAuth(gitlabUrl = "https://gitlab.com") {
|
|
504
|
+
const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
|
|
505
|
+
const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || "http://127.0.0.1:8888/callback";
|
|
506
|
+
const tokenStoragePath = process.env.GITLAB_OAUTH_TOKEN_PATH;
|
|
507
|
+
if (!clientId) {
|
|
508
|
+
throw new Error("GITLAB_OAUTH_CLIENT_ID environment variable is required for OAuth authentication");
|
|
509
|
+
}
|
|
510
|
+
const oauth = new GitLabOAuth({
|
|
511
|
+
clientId,
|
|
512
|
+
redirectUri,
|
|
513
|
+
gitlabUrl,
|
|
514
|
+
scopes: ["api"],
|
|
515
|
+
tokenStoragePath,
|
|
516
|
+
});
|
|
517
|
+
return await oauth.getAccessToken();
|
|
518
|
+
}
|
package/build/schemas.js
CHANGED
|
@@ -852,8 +852,38 @@ export const ListIssueDiscussionsSchema = z
|
|
|
852
852
|
export const ListMergeRequestDiscussionsSchema = ProjectParamsSchema.extend({
|
|
853
853
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
854
854
|
}).merge(PaginationOptionsSchema);
|
|
855
|
-
|
|
855
|
+
export const GetMergeRequestNotesSchema = ProjectParamsSchema.extend({
|
|
856
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
857
|
+
sort: z.enum(["asc", "desc"]).optional().describe("The sort order of the notes"),
|
|
858
|
+
order_by: z.enum(["created_at", "updated_at"]).optional().describe("The field to sort the notes by"),
|
|
859
|
+
});
|
|
860
|
+
export const GetMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
861
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
862
|
+
note_id: z.coerce.string().describe("The ID of a thread note"),
|
|
863
|
+
});
|
|
864
|
+
// Input schema for updating merge request notes
|
|
856
865
|
export const UpdateMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
866
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
867
|
+
note_id: z.coerce.string().describe("The ID of a thread note"),
|
|
868
|
+
body: z.string().describe("The content of the note or reply"),
|
|
869
|
+
});
|
|
870
|
+
// Input schema for adding a note to a merge request
|
|
871
|
+
export const CreateMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
872
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
873
|
+
body: z.string().describe("The content of the note or reply"),
|
|
874
|
+
});
|
|
875
|
+
// delete a merge request note
|
|
876
|
+
export const DeleteMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
877
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
878
|
+
note_id: z.coerce.string().describe("The ID of a thread note"),
|
|
879
|
+
});
|
|
880
|
+
export const DeleteMergeRequestDiscussionNoteSchema = ProjectParamsSchema.extend({
|
|
881
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
882
|
+
discussion_id: z.coerce.string().describe("The ID of a thread"),
|
|
883
|
+
note_id: z.coerce.string().describe("The ID of a thread note"),
|
|
884
|
+
});
|
|
885
|
+
// Input schema for updating a merge request discussion note
|
|
886
|
+
export const UpdateMergeRequestDiscussionNoteSchema = ProjectParamsSchema.extend({
|
|
857
887
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
858
888
|
discussion_id: z.coerce.string().describe("The ID of a thread"),
|
|
859
889
|
note_id: z.coerce.string().describe("The ID of a thread note"),
|
|
@@ -867,7 +897,7 @@ export const UpdateMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
|
867
897
|
message: "Only one of 'body' or 'resolved' can be provided, not both",
|
|
868
898
|
});
|
|
869
899
|
// Input schema for adding a note to an existing merge request discussion
|
|
870
|
-
export const
|
|
900
|
+
export const CreateMergeRequestDiscussionNoteSchema = ProjectParamsSchema.extend({
|
|
871
901
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
872
902
|
discussion_id: z.coerce.string().describe("The ID of a thread"),
|
|
873
903
|
body: z.string().describe("The content of the note or reply"),
|
|
@@ -1382,7 +1412,7 @@ export const MergeRequestThreadPositionCreateSchema = z.object({
|
|
|
1382
1412
|
old_path: z.string().nullable().optional().describe("File path before changes. REQUIRED for most diff comments. Use same as new_path if file wasn't renamed."),
|
|
1383
1413
|
new_line: z.number().nullable().optional().describe("Line number in modified file (after changes). Use for added lines or context lines. NULL for deleted lines. For single-line comments on new lines."),
|
|
1384
1414
|
old_line: z.number().nullable().optional().describe("Line number in original file (before changes). Use for deleted lines or context lines. NULL for added lines. For single-line comments on old lines."),
|
|
1385
|
-
line_range: LineRangeSchema.optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
|
|
1415
|
+
line_range: LineRangeSchema.nullable().optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
|
|
1386
1416
|
width: z.number().optional().describe("IMAGE DIFFS ONLY: Width of the image (for position_type='image')."),
|
|
1387
1417
|
height: z.number().optional().describe("IMAGE DIFFS ONLY: Height of the image (for position_type='image')."),
|
|
1388
1418
|
x: z.number().optional().describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
|
|
@@ -1421,7 +1451,7 @@ export const MergeRequestThreadPositionSchema = z.object({
|
|
|
1421
1451
|
.nullable()
|
|
1422
1452
|
.optional()
|
|
1423
1453
|
.describe("Line number in original file (before changes). Use for deleted lines or context lines. NULL for added lines. For single-line comments on old lines."),
|
|
1424
|
-
line_range: LineRangeSchema.optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
|
|
1454
|
+
line_range: LineRangeSchema.nullable().optional().describe("MULTILINE COMMENTS: Specify start/end line positions for commenting on multiple lines. Alternative to single old_line/new_line."),
|
|
1425
1455
|
width: z
|
|
1426
1456
|
.number()
|
|
1427
1457
|
.optional()
|
|
@@ -1504,6 +1534,11 @@ export const CreateMergeRequestThreadSchema = ProjectParamsSchema.extend({
|
|
|
1504
1534
|
position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
|
|
1505
1535
|
created_at: z.string().optional().describe("Date the thread was created at (ISO 8601 format)"),
|
|
1506
1536
|
});
|
|
1537
|
+
export const ResolveMergeRequestThreadSchema = ProjectParamsSchema.extend({
|
|
1538
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
1539
|
+
discussion_id: z.coerce.string().describe("The ID of a thread"),
|
|
1540
|
+
resolved: z.boolean().describe("Whether to resolve the thread"),
|
|
1541
|
+
});
|
|
1507
1542
|
// Milestone related schemas
|
|
1508
1543
|
// Schema for listing project milestones
|
|
1509
1544
|
export const ListProjectMilestonesSchema = ProjectParamsSchema.extend({
|