@zereight/mcp-gitlab 2.2.6 → 2.3.1
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.ko.md +7 -4
- package/README.md +11 -7
- package/README.pt-BR.md +7 -4
- package/README.zh-CN.md +7 -4
- package/build/cli/args.js +2 -0
- package/build/config.js +4 -0
- package/build/deprecated-env.js +3 -1
- package/build/index.js +316 -82
- package/build/oauth-consent.js +323 -0
- package/build/oauth-proxy.js +99 -12
- package/build/oauth.js +147 -3
- package/build/schemas.js +124 -147
- package/build/stateless/types.js +2 -0
- package/build/tools/exposed-tools.js +14 -0
- package/build/tools/registry.js +24 -4
- package/build/tools/tool-descriptions.js +59 -17
- package/build/utils/graphql-query.js +1 -2
- package/build/utils/helpers.js +63 -4
- package/build/utils/safe-redirect-fetch.js +1 -1
- package/build/utils/url.js +1 -0
- package/package.json +4 -3
package/build/oauth.js
CHANGED
|
@@ -44,6 +44,117 @@ export function isAuthInvalidTokenResponse(status, bodyText) {
|
|
|
44
44
|
return false;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
// Shared OAuth callback servers started by this process
|
|
48
|
+
const sharedOAuthServers = new Set();
|
|
49
|
+
/**
|
|
50
|
+
* Stop the shared OAuth callback servers started by this process.
|
|
51
|
+
*/
|
|
52
|
+
export async function closeSharedOAuthServersAsync() {
|
|
53
|
+
const servers = [...sharedOAuthServers];
|
|
54
|
+
sharedOAuthServers.clear();
|
|
55
|
+
await Promise.all(servers.map(server => new Promise(resolve => {
|
|
56
|
+
server.close(() => resolve());
|
|
57
|
+
server.closeAllConnections();
|
|
58
|
+
})));
|
|
59
|
+
}
|
|
60
|
+
const SHARED_OAUTH_KEY_HEADER = "x-gitlab-mcp-oauth-key";
|
|
61
|
+
/**
|
|
62
|
+
* Key file for the shared OAuth callback server on `port`. Sibling processes
|
|
63
|
+
* compute the same path, so they need the same OS user and home directory.
|
|
64
|
+
*/
|
|
65
|
+
function sharedOAuthKeyPath(port) {
|
|
66
|
+
return path.join(os.homedir(), `.gitlab-mcp-oauth-${port}.key`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Write the key to an owner-only file without following symlinks. The caller
|
|
70
|
+
* holds the port, so an existing regular file of ours is left from an earlier run.
|
|
71
|
+
*/
|
|
72
|
+
function writeSharedOAuthKey(keyPath, key) {
|
|
73
|
+
const flags = fs.constants.O_WRONLY |
|
|
74
|
+
fs.constants.O_CREAT |
|
|
75
|
+
fs.constants.O_EXCL |
|
|
76
|
+
(fs.constants.O_NOFOLLOW ?? 0);
|
|
77
|
+
let fd;
|
|
78
|
+
try {
|
|
79
|
+
fd = fs.openSync(keyPath, flags, 0o600);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code !== "EEXIST") {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
const stat = fs.lstatSync(keyPath);
|
|
86
|
+
const uid = process.getuid?.();
|
|
87
|
+
if (!stat.isFile() || (uid !== undefined && stat.uid !== uid)) {
|
|
88
|
+
throw new Error(`Refusing to replace ${keyPath}: not a regular file owned by the current user`);
|
|
89
|
+
}
|
|
90
|
+
fs.unlinkSync(keyPath);
|
|
91
|
+
fd = fs.openSync(keyPath, flags, 0o600);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
fs.writeSync(fd, key);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
fs.closeSync(fd);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function removeSharedOAuthKey(keyPath, key) {
|
|
101
|
+
try {
|
|
102
|
+
if (fs.readFileSync(keyPath, "utf8").trim() === key) {
|
|
103
|
+
fs.unlinkSync(keyPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Best effort
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function readSharedOAuthKeyAsync(port) {
|
|
111
|
+
const keyPath = sharedOAuthKeyPath(port);
|
|
112
|
+
let problem = "ENOENT";
|
|
113
|
+
// The other process writes its key right after it binds the port.
|
|
114
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
115
|
+
if (attempt > 0) {
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const key = fs.readFileSync(keyPath, "utf8").trim();
|
|
120
|
+
if (/^[0-9a-f]{64}$/.test(key)) {
|
|
121
|
+
return key;
|
|
122
|
+
}
|
|
123
|
+
problem = "unexpected content";
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
problem = error.code ?? String(error);
|
|
127
|
+
if (problem !== "ENOENT") {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`cannot read OAuth server key ${keyPath} (${problem}); the process holding port ${port} ` +
|
|
133
|
+
"must run as the same OS user with the same home directory");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Host headers the shared OAuth callback server answers: loopback names with
|
|
137
|
+
* its port, plus the configured redirect URI host. The listener is bound to
|
|
138
|
+
* 127.0.0.1, so any other Host comes from a name that merely resolves there
|
|
139
|
+
* (DNS rebinding).
|
|
140
|
+
*/
|
|
141
|
+
function sharedOAuthAllowedHosts(port, redirectUri) {
|
|
142
|
+
const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
143
|
+
try {
|
|
144
|
+
hosts.add(new URL(redirectUri).host.toLowerCase());
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Malformed redirectUri: loopback names only
|
|
148
|
+
}
|
|
149
|
+
return hosts;
|
|
150
|
+
}
|
|
151
|
+
function sharedOAuthKeyMatches(provided, expected) {
|
|
152
|
+
if (typeof provided !== "string") {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const digest = (value) => crypto.createHash("sha256").update(value).digest();
|
|
156
|
+
return crypto.timingSafeEqual(digest(provided), digest(expected));
|
|
157
|
+
}
|
|
47
158
|
/**
|
|
48
159
|
* Check if a port is already in use
|
|
49
160
|
*/
|
|
@@ -69,12 +180,14 @@ async function isPortInUse(port) {
|
|
|
69
180
|
* Request authentication from an existing OAuth server
|
|
70
181
|
*/
|
|
71
182
|
async function requestAuthFromExistingServer(port, requestId) {
|
|
183
|
+
const key = await readSharedOAuthKeyAsync(port);
|
|
72
184
|
return new Promise((resolve, reject) => {
|
|
73
185
|
const options = {
|
|
74
186
|
hostname: "127.0.0.1",
|
|
75
187
|
port: port,
|
|
76
188
|
path: `/auth-request?requestId=${requestId}`,
|
|
77
189
|
method: "GET",
|
|
190
|
+
headers: { [SHARED_OAUTH_KEY_HEADER]: key },
|
|
78
191
|
};
|
|
79
192
|
const req = http.request(options, res => {
|
|
80
193
|
let data = "";
|
|
@@ -91,6 +204,9 @@ async function requestAuthFromExistingServer(port, requestId) {
|
|
|
91
204
|
reject(new Error(`Failed to parse token data: ${error}`));
|
|
92
205
|
}
|
|
93
206
|
}
|
|
207
|
+
else if (res.statusCode === 403) {
|
|
208
|
+
reject(new Error(`OAuth server on port ${port} rejected the request key; it may run as a different OS user or an older version`));
|
|
209
|
+
}
|
|
94
210
|
else {
|
|
95
211
|
reject(new Error(`Auth request failed with status ${res.statusCode}: ${data}`));
|
|
96
212
|
}
|
|
@@ -288,7 +404,8 @@ export class GitLabOAuth {
|
|
|
288
404
|
}
|
|
289
405
|
catch (error) {
|
|
290
406
|
logger.error({ err: error }, "Failed to connect to existing OAuth server");
|
|
291
|
-
|
|
407
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
408
|
+
throw new Error(`Port ${callbackPort} is in use but cannot connect to existing OAuth server: ${reason}. Please close other instances or use a different port.`);
|
|
292
409
|
}
|
|
293
410
|
}
|
|
294
411
|
// Port is free, start the shared OAuth server
|
|
@@ -300,6 +417,9 @@ export class GitLabOAuth {
|
|
|
300
417
|
async startSharedOAuthServer(callbackPort, initialRequestId) {
|
|
301
418
|
const stateToRequestId = new Map();
|
|
302
419
|
const requestIdToOAuthInstance = new Map();
|
|
420
|
+
const allowedHosts = sharedOAuthAllowedHosts(callbackPort, this.config.redirectUri);
|
|
421
|
+
const keyPath = sharedOAuthKeyPath(callbackPort);
|
|
422
|
+
const key = crypto.randomBytes(32).toString("hex");
|
|
303
423
|
return new Promise((resolve, reject) => {
|
|
304
424
|
// Create initial request
|
|
305
425
|
const state = crypto.randomUUID();
|
|
@@ -312,9 +432,20 @@ export class GitLabOAuth {
|
|
|
312
432
|
pendingAuthRequests.set(initialRequestId, { resolve, reject, timeout });
|
|
313
433
|
const server = http.createServer(async (req, res) => {
|
|
314
434
|
try {
|
|
435
|
+
// Only answer requests addressed to this listener by an expected name
|
|
436
|
+
if (!allowedHosts.has((req.headers.host || "").toLowerCase())) {
|
|
437
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
438
|
+
res.end("Forbidden");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
315
441
|
const parsedUrl = url.parse(req.url || "", true);
|
|
316
442
|
// Handle auth requests from other MCP instances
|
|
317
443
|
if (parsedUrl.pathname === "/auth-request") {
|
|
444
|
+
if (!sharedOAuthKeyMatches(req.headers[SHARED_OAUTH_KEY_HEADER], key)) {
|
|
445
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
446
|
+
res.end("Forbidden");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
318
449
|
const newRequestId = parsedUrl.query.requestId;
|
|
319
450
|
if (!newRequestId) {
|
|
320
451
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
@@ -331,7 +462,7 @@ export class GitLabOAuth {
|
|
|
331
462
|
const authUrl = await this.getAuthorizationUrl(newState);
|
|
332
463
|
logger.info("Opening browser for new authentication request...");
|
|
333
464
|
logger.info(`If browser doesn't open, visit: ${redactUrlSecretsInText(authUrl)}`);
|
|
334
|
-
open(authUrl).catch(err => {
|
|
465
|
+
(this.config.openBrowser ?? open)(authUrl).catch(err => {
|
|
335
466
|
logger.error({ err }, "Failed to open browser");
|
|
336
467
|
logger.info(`Please manually open: ${redactUrlSecretsInText(authUrl)}`);
|
|
337
468
|
});
|
|
@@ -483,11 +614,24 @@ export class GitLabOAuth {
|
|
|
483
614
|
}
|
|
484
615
|
});
|
|
485
616
|
server.listen(callbackPort, "127.0.0.1", async () => {
|
|
617
|
+
sharedOAuthServers.add(server);
|
|
618
|
+
try {
|
|
619
|
+
writeSharedOAuthKey(keyPath, key);
|
|
620
|
+
const removeKey = () => removeSharedOAuthKey(keyPath, key);
|
|
621
|
+
process.once("exit", removeKey);
|
|
622
|
+
server.once("close", () => {
|
|
623
|
+
process.removeListener("exit", removeKey);
|
|
624
|
+
removeKey();
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
logger.warn({ err: error }, `Failed to write ${keyPath}; other MCP instances cannot use this OAuth callback server`);
|
|
629
|
+
}
|
|
486
630
|
logger.info(`Shared OAuth callback server listening on port ${callbackPort}`);
|
|
487
631
|
const authUrl = await this.getAuthorizationUrl(state);
|
|
488
632
|
logger.info("Opening browser for authentication...");
|
|
489
633
|
logger.info(`If browser doesn't open, visit: ${redactUrlSecretsInText(authUrl)}`);
|
|
490
|
-
open(authUrl).catch(err => {
|
|
634
|
+
(this.config.openBrowser ?? open)(authUrl).catch(err => {
|
|
491
635
|
logger.error({ err }, "Failed to open browser");
|
|
492
636
|
logger.info(`Please manually open: ${redactUrlSecretsInText(authUrl)}`);
|
|
493
637
|
});
|