@zereight/mcp-gitlab 1.0.75 → 2.0.0-beta.0
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 +81 -67
- package/build/customSchemas.js +13 -4
- package/build/index.js +673 -38
- package/build/schemas.js +553 -100
- package/build/src/argon2wrapper.js +68 -0
- package/build/src/authentication.js +78 -0
- package/build/src/authhelpers.js +44 -0
- package/build/src/config.js +99 -0
- package/build/src/customSchemas.js +21 -0
- package/build/src/gitlabhandler.js +1649 -0
- package/build/src/gitlabsession.js +103 -0
- package/build/src/logger.js +11 -0
- package/build/src/mcpserver.js +1331 -0
- package/build/src/oauth.js +389 -0
- package/build/src/schemas.js +1678 -0
- package/package.json +3 -2
- package/build/utils.js +0 -9
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { argon2id } from '@noble/hashes/argon2';
|
|
2
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
// Default options similar to @node-rs/argon2
|
|
5
|
+
const DEFAULT_OPTIONS = {
|
|
6
|
+
t: 3, // iterations
|
|
7
|
+
m: 65536, // 64MB memory
|
|
8
|
+
p: 4, // parallelism
|
|
9
|
+
maxmem: 2 ** 32 - 1
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Hash a password using argon2id
|
|
13
|
+
*/
|
|
14
|
+
export async function hash(password, options) {
|
|
15
|
+
const salt = options?.salt || randomBytes(16);
|
|
16
|
+
const passwordBytes = utf8ToBytes(password);
|
|
17
|
+
const hashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS);
|
|
18
|
+
// Store salt and hash together for verification later
|
|
19
|
+
// Format: salt:hash (both as hex)
|
|
20
|
+
return `${bytesToHex(salt)}:${bytesToHex(hashBytes)}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Synchronous version of hash
|
|
24
|
+
*/
|
|
25
|
+
export function hashSync(password, options) {
|
|
26
|
+
const salt = options?.salt || randomBytes(16);
|
|
27
|
+
const passwordBytes = utf8ToBytes(password);
|
|
28
|
+
const hashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS);
|
|
29
|
+
// Store salt and hash together for verification later
|
|
30
|
+
// Format: salt:hash (both as hex)
|
|
31
|
+
return `${bytesToHex(salt)}:${bytesToHex(hashBytes)}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Verify a password against a hash
|
|
35
|
+
*/
|
|
36
|
+
export async function verify(storedHash, password, options) {
|
|
37
|
+
try {
|
|
38
|
+
// If options.salt is provided, it means we're using the old format
|
|
39
|
+
// where salt was provided separately
|
|
40
|
+
if (options?.salt) {
|
|
41
|
+
const passwordBytes = utf8ToBytes(password);
|
|
42
|
+
const hashBytes = argon2id(passwordBytes, options.salt, DEFAULT_OPTIONS);
|
|
43
|
+
const newHash = bytesToHex(hashBytes);
|
|
44
|
+
// storedHash might be just the hash part without salt prefix
|
|
45
|
+
const hashPart = storedHash.includes(':') ? storedHash.split(':')[1] : storedHash;
|
|
46
|
+
return newHash === hashPart;
|
|
47
|
+
}
|
|
48
|
+
// New format: salt:hash
|
|
49
|
+
const [saltHex, hashHex] = storedHash.split(':');
|
|
50
|
+
if (!saltHex || !hashHex) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const salt = hexToBytes(saltHex);
|
|
54
|
+
const passwordBytes = utf8ToBytes(password);
|
|
55
|
+
const computedHashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS);
|
|
56
|
+
const computedHashHex = bytesToHex(computedHashBytes);
|
|
57
|
+
return computedHashHex === hashHex;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Export as default object to match @node-rs/argon2 interface
|
|
64
|
+
export default {
|
|
65
|
+
hash,
|
|
66
|
+
hashSync,
|
|
67
|
+
verify
|
|
68
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
2
|
+
import { config } from './config.js';
|
|
3
|
+
import { createGitLabOAuthProvider } from './oauth.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Configure authentication middleware based on the configuration
|
|
7
|
+
* Supports OAuth2, PAT passthrough, and static PAT modes
|
|
8
|
+
*/
|
|
9
|
+
export async function configureAuthentication(app) {
|
|
10
|
+
// Default middleware that does nothing
|
|
11
|
+
let authMiddleware = undefined;
|
|
12
|
+
// OAuth2 mode
|
|
13
|
+
if (config.GITLAB_OAUTH2_CLIENT_ID) {
|
|
14
|
+
logger.warn("Configuring GitLab OAuth2 proxy authentication");
|
|
15
|
+
logger.warn("Please note that GitLab OAuth2 proxy authentication is not yet fully supported. Use this feature at your own risk");
|
|
16
|
+
// Create the provider
|
|
17
|
+
const provider = await createGitLabOAuthProvider();
|
|
18
|
+
// Add the callback handler route BEFORE the OAuth router
|
|
19
|
+
app.get("/callback", (req, res) => provider.handleOAuthCallback(req, res));
|
|
20
|
+
// Set up OAuth2 proxy router
|
|
21
|
+
const oauth2Router = provider.createOAuth2Router();
|
|
22
|
+
app.use(oauth2Router);
|
|
23
|
+
// Create token verifier and bearer auth middleware
|
|
24
|
+
const tokenVerifier = provider.createTokenVerifier();
|
|
25
|
+
const bearerAuthMiddleware = requireBearerAuth({
|
|
26
|
+
verifier: tokenVerifier,
|
|
27
|
+
resourceMetadataUrl: `${config.GITLAB_OAUTH2_BASE_URL}/.well-known/oauth-protected-resource`
|
|
28
|
+
});
|
|
29
|
+
authMiddleware = (req, res, next) => {
|
|
30
|
+
// Ensure GitLab-Token header is not set in OAuth2 mode
|
|
31
|
+
const gitlabToken = req.headers["gitlab-token"];
|
|
32
|
+
if (gitlabToken) {
|
|
33
|
+
res.status(401).send("Gitlab-Token header must not be set when MCP is running in OAuth2 mode");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
bearerAuthMiddleware(req, res, next);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// PAT passthrough mode
|
|
40
|
+
else if (config.GITLAB_PAT_PASSTHROUGH) {
|
|
41
|
+
logger.info("Configuring GitLab PAT passthrough authentication. Users must set the Gitlab-Token header in their requests");
|
|
42
|
+
authMiddleware = (req, res, next) => {
|
|
43
|
+
// Check the Gitlab-Token header
|
|
44
|
+
const token = req.headers["gitlab-token"];
|
|
45
|
+
if (!token) {
|
|
46
|
+
res.status(401).send("Please set a Gitlab-Token header in your request");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (typeof token !== "string") {
|
|
50
|
+
res.status(401).send("Gitlab-Token must only be set once");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
req.auth = {
|
|
54
|
+
token: token,
|
|
55
|
+
clientId: "!passthrough",
|
|
56
|
+
scopes: [],
|
|
57
|
+
};
|
|
58
|
+
next();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Static PAT mode
|
|
62
|
+
else if (config.GITLAB_PERSONAL_ACCESS_TOKEN) {
|
|
63
|
+
logger.info("Configuring static GitLab Personal Access Token authentication");
|
|
64
|
+
const accessToken = config.GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
65
|
+
authMiddleware = (req, res, next) => {
|
|
66
|
+
req.auth = {
|
|
67
|
+
token: accessToken,
|
|
68
|
+
clientId: "!global",
|
|
69
|
+
scopes: [],
|
|
70
|
+
};
|
|
71
|
+
next();
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (authMiddleware === undefined) {
|
|
75
|
+
throw new Error("No authMiddleware configured. This is a bug. Please report it.");
|
|
76
|
+
}
|
|
77
|
+
return authMiddleware;
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { config } from "./config.js";
|
|
2
|
+
import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
// Create cookie jar with clean Netscape file parsing
|
|
6
|
+
export const createCookieJar = () => {
|
|
7
|
+
if (!config.GITLAB_AUTH_COOKIE_PATH)
|
|
8
|
+
return undefined;
|
|
9
|
+
try {
|
|
10
|
+
const cookiePath = config.GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
|
|
11
|
+
? path.join(process.env.HOME || "", config.GITLAB_AUTH_COOKIE_PATH.slice(2))
|
|
12
|
+
: config.GITLAB_AUTH_COOKIE_PATH;
|
|
13
|
+
const jar = new CookieJar();
|
|
14
|
+
const cookieContent = fs.readFileSync(cookiePath, "utf8");
|
|
15
|
+
cookieContent.split("\n").forEach(line => {
|
|
16
|
+
// Handle #HttpOnly_ prefix
|
|
17
|
+
if (line.startsWith("#HttpOnly_")) {
|
|
18
|
+
line = line.slice(10);
|
|
19
|
+
}
|
|
20
|
+
// Skip comments and empty lines
|
|
21
|
+
if (line.startsWith("#") || !line.trim()) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Parse Netscape format: domain, flag, path, secure, expires, name, value
|
|
25
|
+
const parts = line.split("\t");
|
|
26
|
+
if (parts.length >= 7) {
|
|
27
|
+
const [domain, , path, secure, expires, name, value] = parts;
|
|
28
|
+
// Build cookie string in standard format
|
|
29
|
+
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`;
|
|
30
|
+
// Use tough-cookie's parse function for robust parsing
|
|
31
|
+
const cookie = parseCookie(cookieStr);
|
|
32
|
+
if (cookie) {
|
|
33
|
+
const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
|
|
34
|
+
jar.setCookieSync(cookie, url);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return jar;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.error("Error loading cookie file:", error);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const unsafeDefaultArgon2Salt = "change-me-in-production";
|
|
2
|
+
export const config = {
|
|
3
|
+
HOST: process.env.HOST || '127.0.0.1',
|
|
4
|
+
PORT: process.env.PORT || 3002,
|
|
5
|
+
SSE: process.env.SSE === "true",
|
|
6
|
+
STREAMABLE_HTTP: process.env.STREAMABLE_HTTP === "true",
|
|
7
|
+
IS_OLD: process.env.GITLAB_IS_OLD === "true",
|
|
8
|
+
GITLAB_READ_ONLY_MODE: process.env.GITLAB_READ_ONLY_MODE === "true",
|
|
9
|
+
USE_GITLAB_WIKI: process.env.USE_GITLAB_WIKI === "true",
|
|
10
|
+
USE_MILESTONE: process.env.USE_MILESTONE === "true",
|
|
11
|
+
USE_PIPELINE: process.env.USE_PIPELINE === "true",
|
|
12
|
+
// Add proxy configuration
|
|
13
|
+
HTTP_PROXY: process.env.HTTP_PROXY,
|
|
14
|
+
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
|
15
|
+
NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED,
|
|
16
|
+
GITLAB_CA_CERT_PATH: process.env.GITLAB_CA_CERT_PATH,
|
|
17
|
+
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
18
|
+
GITLAB_API_URL: normalizeGitLabApiUrl(process.env.GITLAB_API_URL || undefined),
|
|
19
|
+
GITLAB_PROJECT_ID: process.env.GITLAB_PROJECT_ID,
|
|
20
|
+
ARGON2_SALT: process.env.ARGON2_SALT || unsafeDefaultArgon2Salt,
|
|
21
|
+
// Configure cookie auth path, for gitlab instances which require it
|
|
22
|
+
// TODO: investigate the consequences of this with oauth2 and pat passthrough. should it only be used in PAT mode and not passthrough?
|
|
23
|
+
GITLAB_AUTH_COOKIE_PATH: process.env.GITLAB_AUTH_COOKIE_PATH,
|
|
24
|
+
// only one of GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH
|
|
25
|
+
// Gitlab PAT configuration. use this PAT to authenticate all requests
|
|
26
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: process.env.GITLAB_PERSONAL_ACCESS_TOKEN,
|
|
27
|
+
// Gitlab PAT passthrough. pass through the PRIVATE-TOKEN header to make the request to the Gitlab API
|
|
28
|
+
// should be "true" to enable
|
|
29
|
+
GITLAB_PAT_PASSTHROUGH: process.env.GITLAB_PAT_PASSTHROUGH === "true",
|
|
30
|
+
// GitLab OAuth2 configuration
|
|
31
|
+
GITLAB_OAUTH2_CLIENT_ID: process.env.GITLAB_OAUTH2_CLIENT_ID,
|
|
32
|
+
GITLAB_OAUTH2_CLIENT_SECRET: process.env.GITLAB_OAUTH2_CLIENT_SECRET,
|
|
33
|
+
GITLAB_OAUTH2_REDIRECT_URL: process.env.GITLAB_OAUTH2_REDIRECT_URL,
|
|
34
|
+
// we need a database in order for the oauth2 provider to persist clients across restarts.
|
|
35
|
+
GITLAB_OAUTH2_DB_PATH: process.env.GITLAB_OAUTH2_DB_PATH || ":memory:",
|
|
36
|
+
// base url matters for the redirect url, i think?
|
|
37
|
+
GITLAB_OAUTH2_BASE_URL: process.env.GITLAB_OAUTH2_BASE_URL, // http://localhost:3002
|
|
38
|
+
// TODO: maybe thse can be formed based off of the ISSUER_URL? im not sure... (could introduce problems if gitlab ever changes these endpoints, though i doubt they will)
|
|
39
|
+
GITLAB_OAUTH2_TOKEN_URL: process.env.GITLAB_OAUTH2_TOKEN_URL, // https://gitlab.com/oauth/token
|
|
40
|
+
GITLAB_OAUTH2_AUTHORIZATION_URL: process.env.GITLAB_OAUTH2_AUTHORIZATION_URL, // https://gitlab.com/oauth/authorize
|
|
41
|
+
GITLAB_OAUTH2_REVOCATION_URL: process.env.GITLAB_OAUTH2_REVOCATION_URL, // https://gitlab.com/oauth/revoke
|
|
42
|
+
GITLAB_OAUTH2_INTROSPECTION_URL: process.env.GITLAB_OAUTH2_INTROSPECTION_URL, // https://gitlab.com/oauth/introspect
|
|
43
|
+
GITLAB_OAUTH2_REGISTRATION_URL: process.env.GITLAB_OAUTH2_REGISTRATION_URL, // ?
|
|
44
|
+
GITLAB_OAUTH2_ISSUER_URL: process.env.GITLAB_OAUTH2_ISSUER_URL, // https://gitlab.com
|
|
45
|
+
};
|
|
46
|
+
export const validateConfiguration = () => {
|
|
47
|
+
// Check if using default ARGON2_SALT
|
|
48
|
+
if (config.ARGON2_SALT === unsafeDefaultArgon2Salt) {
|
|
49
|
+
console.error('\n' + '='.repeat(80));
|
|
50
|
+
console.error('⚠️ WARNING: USING DEFAULT ARGON2_SALT VALUE!');
|
|
51
|
+
console.error('='.repeat(80));
|
|
52
|
+
console.error('');
|
|
53
|
+
console.error('You are using the default ARGON2_SALT value which is INSECURE.');
|
|
54
|
+
console.error('This salt is publicly known and makes your password hashes vulnerable.');
|
|
55
|
+
console.error('');
|
|
56
|
+
console.error('Please set the ARGON2_SALT environment variable to a unique, random value:');
|
|
57
|
+
console.error(' export ARGON2_SALT="your-unique-random-salt-here"');
|
|
58
|
+
console.error('');
|
|
59
|
+
console.error('You can generate a secure salt with:');
|
|
60
|
+
console.error(' openssl rand -base64 32');
|
|
61
|
+
console.error('');
|
|
62
|
+
console.error('='.repeat(80) + '\n');
|
|
63
|
+
}
|
|
64
|
+
// check that only one of GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH is set
|
|
65
|
+
const onlyOnOf = [
|
|
66
|
+
config.GITLAB_PERSONAL_ACCESS_TOKEN,
|
|
67
|
+
config.GITLAB_OAUTH2_CLIENT_ID,
|
|
68
|
+
config.GITLAB_PAT_PASSTHROUGH
|
|
69
|
+
];
|
|
70
|
+
const countOfSet = onlyOnOf.filter(x => !!x).length;
|
|
71
|
+
const allVariableNames = "GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH";
|
|
72
|
+
if (countOfSet == 0) {
|
|
73
|
+
console.error(`One of the following variables must be set: ${allVariableNames}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
if (countOfSet > 1) {
|
|
77
|
+
console.error(`Only one of the following variables can be set: ${allVariableNames}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Smart URL handling for GitLab API
|
|
83
|
+
*
|
|
84
|
+
* @param {string | undefined} url - Input GitLab API URL
|
|
85
|
+
* @returns {string} Normalized GitLab API URL with /api/v4 path
|
|
86
|
+
*/
|
|
87
|
+
function normalizeGitLabApiUrl(url) {
|
|
88
|
+
if (!url) {
|
|
89
|
+
return "https://gitlab.com/api/v4";
|
|
90
|
+
}
|
|
91
|
+
// Remove trailing slash if present
|
|
92
|
+
let normalizedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
|
93
|
+
// Check if URL already has /api/v4
|
|
94
|
+
if (!normalizedUrl.endsWith("/api/v4") && !normalizedUrl.endsWith("/api/v4/")) {
|
|
95
|
+
// Append /api/v4 if not already present
|
|
96
|
+
normalizedUrl = `${normalizedUrl}/api/v4`;
|
|
97
|
+
}
|
|
98
|
+
return normalizedUrl;
|
|
99
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { pino } from 'pino';
|
|
3
|
+
const DEFAULT_NULL = process.env.DEFAULT_NULL === "true";
|
|
4
|
+
export const logger = pino({
|
|
5
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
6
|
+
transport: {
|
|
7
|
+
target: 'pino-pretty',
|
|
8
|
+
options: {
|
|
9
|
+
colorize: true,
|
|
10
|
+
levelFirst: true,
|
|
11
|
+
destination: 2,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
export const flexibleBoolean = z.preprocess((val) => {
|
|
16
|
+
if (typeof val === 'string') {
|
|
17
|
+
return val.toLowerCase() === 'true';
|
|
18
|
+
}
|
|
19
|
+
return val;
|
|
20
|
+
}, z.boolean());
|
|
21
|
+
export const flexibleBooleanNullable = DEFAULT_NULL ? flexibleBoolean.nullable().default(null) : flexibleBoolean.nullable();
|