@zereight/mcp-gitlab 2.0.9 ā 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 +58 -3
- package/build/index.js +31 -13
- package/build/oauth.js +518 -0
- package/build/schemas.js +2 -2
- package/build/test/oauth-tests.js +389 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -16,7 +16,58 @@ GitLab MCP(Model Context Protocol) Server. **Includes bug fixes and improvements
|
|
|
16
16
|
|
|
17
17
|
When using with the Claude App, you need to set up your API key and URLs directly.
|
|
18
18
|
|
|
19
|
-
####
|
|
19
|
+
#### Authentication Methods
|
|
20
|
+
|
|
21
|
+
The server supports two authentication methods:
|
|
22
|
+
|
|
23
|
+
1. **Personal Access Token** (traditional method)
|
|
24
|
+
2. **OAuth2** (recommended for better security)
|
|
25
|
+
|
|
26
|
+
#### Using OAuth2 Authentication
|
|
27
|
+
|
|
28
|
+
OAuth2 provides a more secure authentication flow using browser-based authentication. When enabled, the server will:
|
|
29
|
+
1. Open your browser to GitLab's authorization page
|
|
30
|
+
2. Wait for you to approve the access
|
|
31
|
+
3. Store the token securely for future use
|
|
32
|
+
4. Automatically refresh the token when it expires
|
|
33
|
+
|
|
34
|
+
For detailed OAuth2 setup instructions, see [OAuth Setup Guide](./docs/oauth-setup.md).
|
|
35
|
+
|
|
36
|
+
Quick setup - first create a GitLab OAuth application:
|
|
37
|
+
|
|
38
|
+
1. Go to your GitLab instance: `Settings` ā `Applications`
|
|
39
|
+
2. Create a new application with:
|
|
40
|
+
- **Name**: `GitLab MCP Server` (or any name you prefer)
|
|
41
|
+
- **Redirect URI**: `http://127.0.0.1:8888/callback`
|
|
42
|
+
- **Scopes**: Select `api` (provides complete read/write access to the API)
|
|
43
|
+
3. Copy the **Application ID** (this is your Client ID)
|
|
44
|
+
|
|
45
|
+
Then configure the MCP server with OAuth:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"gitlab": {
|
|
51
|
+
"command": "npx",
|
|
52
|
+
"args": ["-y", "@zereight/mcp-gitlab"],
|
|
53
|
+
"env": {
|
|
54
|
+
"GITLAB_USE_OAUTH": "true",
|
|
55
|
+
"GITLAB_OAUTH_CLIENT_ID": "your_oauth_client_id",
|
|
56
|
+
"GITLAB_OAUTH_REDIRECT_URI": "http://127.0.0.1:8888/callback",
|
|
57
|
+
"GITLAB_API_URL": "your_gitlab_api_url",
|
|
58
|
+
"GITLAB_PROJECT_ID": "your_project_id", // Optional: default project
|
|
59
|
+
"GITLAB_ALLOWED_PROJECT_IDS": "", // Optional: comma-separated list of allowed project IDs
|
|
60
|
+
"GITLAB_READ_ONLY_MODE": "false",
|
|
61
|
+
"USE_GITLAB_WIKI": "false", // use wiki api?
|
|
62
|
+
"USE_MILESTONE": "false", // use milestone api?
|
|
63
|
+
"USE_PIPELINE": "false" // use pipeline api?
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### Using Personal Access Token (traditional)
|
|
20
71
|
|
|
21
72
|
```json
|
|
22
73
|
{
|
|
@@ -185,7 +236,11 @@ docker run -i --rm \
|
|
|
185
236
|
|
|
186
237
|
#### Authentication Configuration
|
|
187
238
|
|
|
188
|
-
- `GITLAB_PERSONAL_ACCESS_TOKEN`: Your GitLab personal access token. **Required in standard mode**; not used when `REMOTE_AUTHORIZATION=true
|
|
239
|
+
- `GITLAB_PERSONAL_ACCESS_TOKEN`: Your GitLab personal access token. **Required in standard mode**; not used when `REMOTE_AUTHORIZATION=true` or when using OAuth.
|
|
240
|
+
- `GITLAB_USE_OAUTH`: Set to `true` to enable OAuth2 authentication instead of personal access token.
|
|
241
|
+
- `GITLAB_OAUTH_CLIENT_ID`: The Client ID from your GitLab OAuth application. Required when using OAuth.
|
|
242
|
+
- `GITLAB_OAUTH_REDIRECT_URI`: The OAuth callback URL. Default: `http://127.0.0.1:8888/callback`
|
|
243
|
+
- `GITLAB_OAUTH_TOKEN_PATH`: Custom path to store the OAuth token. Default: `~/.gitlab-mcp-token.json`
|
|
189
244
|
- `REMOTE_AUTHORIZATION`: When set to 'true', enables remote per-session authorization via HTTP headers. In this mode:
|
|
190
245
|
- The server accepts GitLab PAT tokens from HTTP headers (`Authorization: Bearer <token>` or `Private-Token: <token>`) on a per-session basis
|
|
191
246
|
- `GITLAB_PERSONAL_ACCESS_TOKEN` environment variable is **not required** and ignored
|
|
@@ -195,7 +250,7 @@ docker run -i --rm \
|
|
|
195
250
|
- Tokens are stored per session and automatically cleaned up when sessions close or timeout
|
|
196
251
|
- `SESSION_TIMEOUT_SECONDS`: Session auth token timeout in seconds. Default: `3600` (1 hour). Valid range: 1-86400 seconds (recommended: 60+). After this period of inactivity, the auth token is removed but the transport session remains active. The client must provide auth headers again on the next request. Only applies when `REMOTE_AUTHORIZATION=true`.
|
|
197
252
|
|
|
198
|
-
####
|
|
253
|
+
#### General Configuration
|
|
199
254
|
|
|
200
255
|
- `GITLAB_API_URL`: Your GitLab API URL. (Default: `https://gitlab.com/api/v4`)
|
|
201
256
|
- `GITLAB_PROJECT_ID`: Default project ID. If set, Overwrite this value when making an API request.
|
package/build/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
|
17
17
|
import { fileURLToPath } from "url";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
20
|
+
import { initializeOAuth } from "./oauth.js";
|
|
20
21
|
// Add type imports for proxy agents
|
|
21
22
|
import { Agent } from "http";
|
|
22
23
|
import { Agent as HttpsAgent } from "https";
|
|
@@ -130,10 +131,11 @@ function validateConfiguration() {
|
|
|
130
131
|
}
|
|
131
132
|
// Validate auth configuration
|
|
132
133
|
const remoteAuth = process.env.REMOTE_AUTHORIZATION === "true";
|
|
134
|
+
const useOAuth = process.env.GITLAB_USE_OAUTH === "true";
|
|
133
135
|
const hasToken = !!process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
134
136
|
const hasCookie = !!process.env.GITLAB_AUTH_COOKIE_PATH;
|
|
135
|
-
if (!remoteAuth && !hasToken && !hasCookie) {
|
|
136
|
-
errors.push('Either GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_AUTH_COOKIE_PATH, or REMOTE_AUTHORIZATION=true must be set');
|
|
137
|
+
if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
|
|
138
|
+
errors.push('Either GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_AUTH_COOKIE_PATH, GITLAB_USE_OAUTH=true, or REMOTE_AUTHORIZATION=true must be set');
|
|
137
139
|
}
|
|
138
140
|
if (errors.length > 0) {
|
|
139
141
|
logger.error('Configuration validation failed:');
|
|
@@ -143,7 +145,9 @@ function validateConfiguration() {
|
|
|
143
145
|
logger.info('Configuration validation passed');
|
|
144
146
|
}
|
|
145
147
|
const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
148
|
+
let OAUTH_ACCESS_TOKEN = null;
|
|
146
149
|
const GITLAB_AUTH_COOKIE_PATH = process.env.GITLAB_AUTH_COOKIE_PATH;
|
|
150
|
+
const USE_OAUTH = process.env.GITLAB_USE_OAUTH === "true";
|
|
147
151
|
const IS_OLD = process.env.GITLAB_IS_OLD === "true";
|
|
148
152
|
const GITLAB_READ_ONLY_MODE = process.env.GITLAB_READ_ONLY_MODE === "true";
|
|
149
153
|
const GITLAB_DENIED_TOOLS_REGEX = process.env.GITLAB_DENIED_TOOLS_REGEX ? new RegExp(process.env.GITLAB_DENIED_TOOLS_REGEX) : undefined;
|
|
@@ -281,12 +285,13 @@ function buildAuthHeaders() {
|
|
|
281
285
|
}
|
|
282
286
|
return {}; // No auth headers if no session context
|
|
283
287
|
}
|
|
284
|
-
// Standard mode:
|
|
285
|
-
|
|
286
|
-
|
|
288
|
+
// Standard mode: prioritize OAuth token, then fall back to environment token
|
|
289
|
+
const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
290
|
+
if (IS_OLD && token) {
|
|
291
|
+
return { 'Private-Token': String(token) };
|
|
287
292
|
}
|
|
288
|
-
if (
|
|
289
|
-
return { Authorization: `Bearer ${
|
|
293
|
+
if (token) {
|
|
294
|
+
return { Authorization: `Bearer ${token}` };
|
|
290
295
|
}
|
|
291
296
|
return {};
|
|
292
297
|
}
|
|
@@ -948,12 +953,11 @@ if (REMOTE_AUTHORIZATION) {
|
|
|
948
953
|
}
|
|
949
954
|
logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
|
|
950
955
|
}
|
|
951
|
-
else {
|
|
952
|
-
// Standard mode: token must be in environment
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
}
|
|
956
|
+
else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
|
|
957
|
+
// Standard mode: token must be in environment (unless using OAuth)
|
|
958
|
+
logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
|
|
959
|
+
logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
|
|
960
|
+
process.exit(1);
|
|
957
961
|
}
|
|
958
962
|
/**
|
|
959
963
|
* Utility function for handling GitLab API errors
|
|
@@ -5199,6 +5203,20 @@ async function runServer() {
|
|
|
5199
5203
|
try {
|
|
5200
5204
|
// Validate configuration before starting server
|
|
5201
5205
|
validateConfiguration();
|
|
5206
|
+
// Initialize OAuth token if OAuth is enabled
|
|
5207
|
+
if (USE_OAUTH) {
|
|
5208
|
+
logger.info("Using OAuth authentication...");
|
|
5209
|
+
try {
|
|
5210
|
+
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
|
|
5211
|
+
OAUTH_ACCESS_TOKEN = await initializeOAuth(gitlabBaseUrl);
|
|
5212
|
+
logger.info("OAuth authentication successful");
|
|
5213
|
+
// Note: Headers are automatically generated by buildAuthHeaders() using OAUTH_ACCESS_TOKEN
|
|
5214
|
+
}
|
|
5215
|
+
catch (error) {
|
|
5216
|
+
logger.error("OAuth authentication failed:", error);
|
|
5217
|
+
process.exit(1);
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5202
5220
|
const transportMode = determineTransportMode();
|
|
5203
5221
|
await initializeServerByTransportMode(transportMode);
|
|
5204
5222
|
}
|
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
|
@@ -1412,7 +1412,7 @@ export const MergeRequestThreadPositionCreateSchema = z.object({
|
|
|
1412
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."),
|
|
1413
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."),
|
|
1414
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."),
|
|
1415
|
-
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."),
|
|
1416
1416
|
width: z.number().optional().describe("IMAGE DIFFS ONLY: Width of the image (for position_type='image')."),
|
|
1417
1417
|
height: z.number().optional().describe("IMAGE DIFFS ONLY: Height of the image (for position_type='image')."),
|
|
1418
1418
|
x: z.number().optional().describe("IMAGE DIFFS ONLY: X coordinate on the image (for position_type='image')."),
|
|
@@ -1451,7 +1451,7 @@ export const MergeRequestThreadPositionSchema = z.object({
|
|
|
1451
1451
|
.nullable()
|
|
1452
1452
|
.optional()
|
|
1453
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."),
|
|
1454
|
-
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."),
|
|
1455
1455
|
width: z
|
|
1456
1456
|
.number()
|
|
1457
1457
|
.optional()
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* OAuth Authentication Tests
|
|
4
|
+
* Tests for GitLab OAuth2 authentication flow
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as http from 'http';
|
|
9
|
+
import * as net from 'net';
|
|
10
|
+
import { GitLabOAuth } from '../oauth.js';
|
|
11
|
+
// Test configuration
|
|
12
|
+
const TEST_CLIENT_ID = process.env.GITLAB_OAUTH_CLIENT_ID || 'test-client-id';
|
|
13
|
+
const TEST_REDIRECT_URI = process.env.GITLAB_OAUTH_REDIRECT_URI || 'http://127.0.0.1:8888/callback';
|
|
14
|
+
const TEST_GITLAB_URL = process.env.GITLAB_API_URL?.replace('/api/v4', '') || 'https://gitlab.com';
|
|
15
|
+
const TEST_TOKEN_PATH = path.join(process.cwd(), '.test-gitlab-token.json');
|
|
16
|
+
const testResults = [];
|
|
17
|
+
// Helper function to run a single test
|
|
18
|
+
async function runTest(name, testFn, skip = false) {
|
|
19
|
+
if (skip) {
|
|
20
|
+
console.log(`āļø SKIPPED: ${name}`);
|
|
21
|
+
testResults.push({ name, status: 'skipped', duration: 0 });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const startTime = Date.now();
|
|
25
|
+
try {
|
|
26
|
+
console.log(`š§Ŗ Testing: ${name}`);
|
|
27
|
+
await testFn();
|
|
28
|
+
const duration = Date.now() - startTime;
|
|
29
|
+
console.log(`ā
PASSED: ${name} (${duration}ms)`);
|
|
30
|
+
testResults.push({ name, status: 'passed', duration });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const duration = Date.now() - startTime;
|
|
34
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
35
|
+
console.log(`ā FAILED: ${name} (${duration}ms)`);
|
|
36
|
+
console.log(` Error: ${errorMsg}`);
|
|
37
|
+
testResults.push({ name, status: 'failed', duration, error: errorMsg });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Helper function to assert conditions
|
|
41
|
+
function assert(condition, message) {
|
|
42
|
+
if (!condition) {
|
|
43
|
+
throw new Error(`Assertion failed: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Helper function to check if port is available
|
|
47
|
+
async function isPortAvailable(port) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const server = net.createServer();
|
|
50
|
+
server.once('error', (err) => {
|
|
51
|
+
if (err.code === 'EADDRINUSE') {
|
|
52
|
+
resolve(false);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
resolve(true);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
server.once('listening', () => {
|
|
59
|
+
server.close();
|
|
60
|
+
resolve(true);
|
|
61
|
+
});
|
|
62
|
+
server.listen(port, '127.0.0.1');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Clean up test token file
|
|
66
|
+
function cleanupTestToken() {
|
|
67
|
+
if (fs.existsSync(TEST_TOKEN_PATH)) {
|
|
68
|
+
fs.unlinkSync(TEST_TOKEN_PATH);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Test 1: GitLabOAuth class instantiation
|
|
72
|
+
async function testOAuthInstantiation() {
|
|
73
|
+
const oauth = new GitLabOAuth({
|
|
74
|
+
clientId: TEST_CLIENT_ID,
|
|
75
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
76
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
77
|
+
scopes: ['api'],
|
|
78
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
79
|
+
});
|
|
80
|
+
assert(oauth !== null, 'OAuth instance should be created');
|
|
81
|
+
assert(typeof oauth.getAccessToken === 'function', 'Should have getAccessToken method');
|
|
82
|
+
assert(typeof oauth.clearToken === 'function', 'Should have clearToken method');
|
|
83
|
+
assert(typeof oauth.hasValidToken === 'function', 'Should have hasValidToken method');
|
|
84
|
+
}
|
|
85
|
+
// Test 2: Token storage path configuration
|
|
86
|
+
async function testTokenStoragePath() {
|
|
87
|
+
const customPath = path.join(process.cwd(), '.custom-test-token.json');
|
|
88
|
+
const oauth = new GitLabOAuth({
|
|
89
|
+
clientId: TEST_CLIENT_ID,
|
|
90
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
91
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
92
|
+
scopes: ['api'],
|
|
93
|
+
tokenStoragePath: customPath,
|
|
94
|
+
});
|
|
95
|
+
assert(oauth !== null, 'OAuth instance with custom path should be created');
|
|
96
|
+
// Clean up
|
|
97
|
+
if (fs.existsSync(customPath)) {
|
|
98
|
+
fs.unlinkSync(customPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Test 3: Scope configuration
|
|
102
|
+
async function testScopeConfiguration() {
|
|
103
|
+
const oauth = new GitLabOAuth({
|
|
104
|
+
clientId: TEST_CLIENT_ID,
|
|
105
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
106
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
107
|
+
scopes: ['api'],
|
|
108
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
109
|
+
});
|
|
110
|
+
assert(oauth !== null, 'OAuth instance with api scope should be created');
|
|
111
|
+
}
|
|
112
|
+
// Test 4: Multiple scopes (should still work but is redundant)
|
|
113
|
+
async function testMultipleScopesRedundant() {
|
|
114
|
+
const oauth = new GitLabOAuth({
|
|
115
|
+
clientId: TEST_CLIENT_ID,
|
|
116
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
117
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
118
|
+
scopes: ['api', 'read_user', 'read_api', 'write_repository'],
|
|
119
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
120
|
+
});
|
|
121
|
+
assert(oauth !== null, 'OAuth instance with multiple scopes should be created');
|
|
122
|
+
}
|
|
123
|
+
// Test 5: hasValidToken returns false when no token exists
|
|
124
|
+
async function testHasValidTokenNoToken() {
|
|
125
|
+
cleanupTestToken();
|
|
126
|
+
const oauth = new GitLabOAuth({
|
|
127
|
+
clientId: TEST_CLIENT_ID,
|
|
128
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
129
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
130
|
+
scopes: ['api'],
|
|
131
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
132
|
+
});
|
|
133
|
+
const hasToken = oauth.hasValidToken();
|
|
134
|
+
assert(hasToken === false, 'Should return false when no token exists');
|
|
135
|
+
}
|
|
136
|
+
// Test 6: hasValidToken returns true with valid token
|
|
137
|
+
async function testHasValidTokenWithToken() {
|
|
138
|
+
const tokenData = {
|
|
139
|
+
access_token: 'test-token',
|
|
140
|
+
token_type: 'Bearer',
|
|
141
|
+
created_at: Date.now(),
|
|
142
|
+
expires_in: 7200, // 2 hours
|
|
143
|
+
};
|
|
144
|
+
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
145
|
+
const oauth = new GitLabOAuth({
|
|
146
|
+
clientId: TEST_CLIENT_ID,
|
|
147
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
148
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
149
|
+
scopes: ['api'],
|
|
150
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
151
|
+
});
|
|
152
|
+
const hasToken = oauth.hasValidToken();
|
|
153
|
+
assert(hasToken === true, 'Should return true with valid token');
|
|
154
|
+
cleanupTestToken();
|
|
155
|
+
}
|
|
156
|
+
// Test 7: hasValidToken returns false with expired token
|
|
157
|
+
async function testHasValidTokenExpired() {
|
|
158
|
+
const tokenData = {
|
|
159
|
+
access_token: 'test-token',
|
|
160
|
+
token_type: 'Bearer',
|
|
161
|
+
created_at: Date.now() - 10000000, // 2.7+ hours ago
|
|
162
|
+
expires_in: 7200, // 2 hours
|
|
163
|
+
};
|
|
164
|
+
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
165
|
+
const oauth = new GitLabOAuth({
|
|
166
|
+
clientId: TEST_CLIENT_ID,
|
|
167
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
168
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
169
|
+
scopes: ['api'],
|
|
170
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
171
|
+
});
|
|
172
|
+
const hasToken = oauth.hasValidToken();
|
|
173
|
+
assert(hasToken === false, 'Should return false with expired token');
|
|
174
|
+
cleanupTestToken();
|
|
175
|
+
}
|
|
176
|
+
// Test 8: clearToken removes token file
|
|
177
|
+
async function testClearToken() {
|
|
178
|
+
const tokenData = {
|
|
179
|
+
access_token: 'test-token',
|
|
180
|
+
token_type: 'Bearer',
|
|
181
|
+
created_at: Date.now(),
|
|
182
|
+
};
|
|
183
|
+
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
184
|
+
const oauth = new GitLabOAuth({
|
|
185
|
+
clientId: TEST_CLIENT_ID,
|
|
186
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
187
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
188
|
+
scopes: ['api'],
|
|
189
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
190
|
+
});
|
|
191
|
+
oauth.clearToken();
|
|
192
|
+
assert(!fs.existsSync(TEST_TOKEN_PATH), 'Token file should be deleted');
|
|
193
|
+
}
|
|
194
|
+
// Test 9: Token file has correct permissions (Unix only)
|
|
195
|
+
async function testTokenFilePermissions() {
|
|
196
|
+
if (process.platform === 'win32') {
|
|
197
|
+
throw new Error('Skipping permission test on Windows');
|
|
198
|
+
}
|
|
199
|
+
const tokenData = {
|
|
200
|
+
access_token: 'test-token',
|
|
201
|
+
token_type: 'Bearer',
|
|
202
|
+
created_at: Date.now(),
|
|
203
|
+
};
|
|
204
|
+
fs.writeFileSync(TEST_TOKEN_PATH, JSON.stringify(tokenData), { mode: 0o600 });
|
|
205
|
+
const stats = fs.statSync(TEST_TOKEN_PATH);
|
|
206
|
+
const mode = stats.mode & 0o777;
|
|
207
|
+
assert(mode === 0o600, `Token file should have 0600 permissions, got ${mode.toString(8)}`);
|
|
208
|
+
cleanupTestToken();
|
|
209
|
+
}
|
|
210
|
+
// Test 10: Port availability check
|
|
211
|
+
async function testPortAvailability() {
|
|
212
|
+
const port = 8888;
|
|
213
|
+
const available = await isPortAvailable(port);
|
|
214
|
+
// We just check that the function works, not the actual availability
|
|
215
|
+
assert(typeof available === 'boolean', 'Port availability check should return boolean');
|
|
216
|
+
}
|
|
217
|
+
// Test 11: OAuth redirect URI parsing
|
|
218
|
+
async function testRedirectUriParsing() {
|
|
219
|
+
const redirectUri = 'http://127.0.0.1:8888/callback';
|
|
220
|
+
const url = new URL(redirectUri);
|
|
221
|
+
assert(url.port === '8888', 'Should correctly parse port from redirect URI');
|
|
222
|
+
assert(url.pathname === '/callback', 'Should correctly parse path from redirect URI');
|
|
223
|
+
assert(url.hostname === '127.0.0.1', 'Should correctly parse hostname from redirect URI');
|
|
224
|
+
}
|
|
225
|
+
// Test 12: Token expiration calculation
|
|
226
|
+
async function testTokenExpirationCalculation() {
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
const expiresIn = 7200; // 2 hours in seconds
|
|
229
|
+
const buffer = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
230
|
+
const expiryTime = now + (expiresIn * 1000);
|
|
231
|
+
const shouldRefreshAt = expiryTime - buffer;
|
|
232
|
+
assert(shouldRefreshAt < expiryTime, 'Refresh time should be before expiry');
|
|
233
|
+
assert(shouldRefreshAt > now, 'Refresh time should be in the future for new token');
|
|
234
|
+
}
|
|
235
|
+
// Test 13: Concurrent OAuth server handling (shared server concept)
|
|
236
|
+
async function testSharedServerConcept() {
|
|
237
|
+
// Test that multiple instances can theoretically share a port
|
|
238
|
+
const port = 9999;
|
|
239
|
+
// First instance: start server
|
|
240
|
+
const server = http.createServer((req, res) => {
|
|
241
|
+
res.writeHead(200);
|
|
242
|
+
res.end('OK');
|
|
243
|
+
});
|
|
244
|
+
await new Promise((resolve) => {
|
|
245
|
+
server.listen(port, '127.0.0.1', () => resolve());
|
|
246
|
+
});
|
|
247
|
+
// Check port is now in use
|
|
248
|
+
const inUse = !(await isPortAvailable(port));
|
|
249
|
+
assert(inUse === true, 'Port should be in use after server starts');
|
|
250
|
+
// Clean up
|
|
251
|
+
await new Promise((resolve) => {
|
|
252
|
+
server.close(() => resolve());
|
|
253
|
+
});
|
|
254
|
+
// Check port is available again
|
|
255
|
+
const available = await isPortAvailable(port);
|
|
256
|
+
assert(available === true, 'Port should be available after server closes');
|
|
257
|
+
}
|
|
258
|
+
// Test 14: Environment variable configuration
|
|
259
|
+
async function testEnvironmentVariableConfig() {
|
|
260
|
+
const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
|
|
261
|
+
const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || 'http://127.0.0.1:8888/callback';
|
|
262
|
+
assert(typeof clientId === 'string' || clientId === undefined, 'Client ID should be string or undefined');
|
|
263
|
+
assert(typeof redirectUri === 'string', 'Redirect URI should be string');
|
|
264
|
+
const url = new URL(redirectUri);
|
|
265
|
+
assert(url.protocol === 'http:', 'Redirect URI should use http protocol for localhost');
|
|
266
|
+
}
|
|
267
|
+
// Test 15: Token data structure validation
|
|
268
|
+
async function testTokenDataStructure() {
|
|
269
|
+
const tokenData = {
|
|
270
|
+
access_token: 'glpat-test123456789',
|
|
271
|
+
refresh_token: 'refresh-test123456789',
|
|
272
|
+
token_type: 'Bearer',
|
|
273
|
+
expires_in: 7200,
|
|
274
|
+
created_at: Date.now(),
|
|
275
|
+
};
|
|
276
|
+
assert(typeof tokenData.access_token === 'string', 'access_token should be string');
|
|
277
|
+
assert(typeof tokenData.token_type === 'string', 'token_type should be string');
|
|
278
|
+
assert(typeof tokenData.created_at === 'number', 'created_at should be number');
|
|
279
|
+
assert(tokenData.expires_in === undefined || typeof tokenData.expires_in === 'number', 'expires_in should be number or undefined');
|
|
280
|
+
}
|
|
281
|
+
// Test 16: Invalid token storage path handling
|
|
282
|
+
async function testInvalidTokenStoragePath() {
|
|
283
|
+
const invalidPath = '/root/nonexistent/directory/.token.json';
|
|
284
|
+
const oauth = new GitLabOAuth({
|
|
285
|
+
clientId: TEST_CLIENT_ID,
|
|
286
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
287
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
288
|
+
scopes: ['api'],
|
|
289
|
+
tokenStoragePath: invalidPath,
|
|
290
|
+
});
|
|
291
|
+
// Should create instance even with invalid path (error occurs during save)
|
|
292
|
+
assert(oauth !== null, 'Should create instance with invalid path');
|
|
293
|
+
}
|
|
294
|
+
// Test 17: Self-hosted GitLab URL configuration
|
|
295
|
+
async function testSelfHostedGitLabUrl() {
|
|
296
|
+
const selfHostedUrl = 'https://gitlab.example.com';
|
|
297
|
+
const oauth = new GitLabOAuth({
|
|
298
|
+
clientId: TEST_CLIENT_ID,
|
|
299
|
+
redirectUri: TEST_REDIRECT_URI,
|
|
300
|
+
gitlabUrl: selfHostedUrl,
|
|
301
|
+
scopes: ['api'],
|
|
302
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
303
|
+
});
|
|
304
|
+
assert(oauth !== null, 'Should create instance with self-hosted URL');
|
|
305
|
+
}
|
|
306
|
+
// Test 18: Custom port in redirect URI
|
|
307
|
+
async function testCustomPortInRedirectUri() {
|
|
308
|
+
const customRedirectUri = 'http://127.0.0.1:9999/callback';
|
|
309
|
+
const oauth = new GitLabOAuth({
|
|
310
|
+
clientId: TEST_CLIENT_ID,
|
|
311
|
+
redirectUri: customRedirectUri,
|
|
312
|
+
gitlabUrl: TEST_GITLAB_URL,
|
|
313
|
+
scopes: ['api'],
|
|
314
|
+
tokenStoragePath: TEST_TOKEN_PATH,
|
|
315
|
+
});
|
|
316
|
+
assert(oauth !== null, 'Should create instance with custom port');
|
|
317
|
+
const url = new URL(customRedirectUri);
|
|
318
|
+
assert(url.port === '9999', 'Should correctly parse custom port');
|
|
319
|
+
}
|
|
320
|
+
// Main test runner
|
|
321
|
+
async function runOAuthTests() {
|
|
322
|
+
console.log('š GitLab OAuth Authentication Tests\n');
|
|
323
|
+
console.log('='.repeat(50));
|
|
324
|
+
// Core functionality tests
|
|
325
|
+
await runTest('OAuth class instantiation', testOAuthInstantiation);
|
|
326
|
+
await runTest('Token storage path configuration', testTokenStoragePath);
|
|
327
|
+
await runTest('Scope configuration with api only', testScopeConfiguration);
|
|
328
|
+
await runTest('Multiple scopes configuration (redundant)', testMultipleScopesRedundant);
|
|
329
|
+
// Token management tests
|
|
330
|
+
await runTest('hasValidToken returns false without token', testHasValidTokenNoToken);
|
|
331
|
+
await runTest('hasValidToken returns true with valid token', testHasValidTokenWithToken);
|
|
332
|
+
await runTest('hasValidToken returns false with expired token', testHasValidTokenExpired);
|
|
333
|
+
await runTest('clearToken removes token file', testClearToken);
|
|
334
|
+
await runTest('Token file has correct permissions', testTokenFilePermissions, process.platform === 'win32');
|
|
335
|
+
// Network and configuration tests
|
|
336
|
+
await runTest('Port availability check', testPortAvailability);
|
|
337
|
+
await runTest('OAuth redirect URI parsing', testRedirectUriParsing);
|
|
338
|
+
await runTest('Token expiration calculation', testTokenExpirationCalculation);
|
|
339
|
+
await runTest('Shared server concept', testSharedServerConcept);
|
|
340
|
+
// Configuration tests
|
|
341
|
+
await runTest('Environment variable configuration', testEnvironmentVariableConfig);
|
|
342
|
+
await runTest('Token data structure validation', testTokenDataStructure);
|
|
343
|
+
await runTest('Invalid token storage path handling', testInvalidTokenStoragePath);
|
|
344
|
+
await runTest('Self-hosted GitLab URL configuration', testSelfHostedGitLabUrl);
|
|
345
|
+
await runTest('Custom port in redirect URI', testCustomPortInRedirectUri);
|
|
346
|
+
// Cleanup
|
|
347
|
+
cleanupTestToken();
|
|
348
|
+
// Print summary
|
|
349
|
+
console.log('\n' + '='.repeat(50));
|
|
350
|
+
console.log('š Test Results Summary\n');
|
|
351
|
+
const passed = testResults.filter(r => r.status === 'passed').length;
|
|
352
|
+
const failed = testResults.filter(r => r.status === 'failed').length;
|
|
353
|
+
const skipped = testResults.filter(r => r.status === 'skipped').length;
|
|
354
|
+
const total = testResults.length;
|
|
355
|
+
console.log(`Total tests: ${total}`);
|
|
356
|
+
console.log(`ā
Passed: ${passed}`);
|
|
357
|
+
console.log(`ā Failed: ${failed}`);
|
|
358
|
+
console.log(`āļø Skipped: ${skipped}`);
|
|
359
|
+
if (total > 0) {
|
|
360
|
+
const successRate = ((passed / (total - skipped)) * 100).toFixed(1);
|
|
361
|
+
console.log(`Success rate: ${successRate}%`);
|
|
362
|
+
}
|
|
363
|
+
// Show failed tests
|
|
364
|
+
const failedTests = testResults.filter(r => r.status === 'failed');
|
|
365
|
+
if (failedTests.length > 0) {
|
|
366
|
+
console.log('\nā Failed Tests:');
|
|
367
|
+
failedTests.forEach(test => {
|
|
368
|
+
console.log(` - ${test.name}`);
|
|
369
|
+
console.log(` ${test.error}`);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// Save results to file
|
|
373
|
+
const reportPath = 'test-results-oauth.json';
|
|
374
|
+
fs.writeFileSync(reportPath, JSON.stringify(testResults, null, 2));
|
|
375
|
+
console.log(`\nš Detailed results saved to ${reportPath}`);
|
|
376
|
+
return failed === 0;
|
|
377
|
+
}
|
|
378
|
+
// Run tests if this is the main module
|
|
379
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
380
|
+
runOAuthTests()
|
|
381
|
+
.then(success => {
|
|
382
|
+
process.exit(success ? 0 : 1);
|
|
383
|
+
})
|
|
384
|
+
.catch(error => {
|
|
385
|
+
console.error('Error running tests:', error);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
export { runOAuthTests, testResults };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.11",
|
|
4
4
|
"description": "MCP server for using the GitLab API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "zereight",
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"test:remote-auth": "npm run build && npx tsx --test test/remote-auth-simple-test.ts",
|
|
28
28
|
"test:server": "npm run build && node build/test/test-all-transport-server.js",
|
|
29
29
|
"test:mcp:readonly": "tsx test/readonly-mcp-tests.ts",
|
|
30
|
-
"test:
|
|
30
|
+
"test:oauth": "tsx test/oauth-tests.ts",
|
|
31
|
+
"test:all": "npm run test && npm run test:mcp:readonly && npm run test:oauth",
|
|
31
32
|
"lint": "eslint . --ext .ts",
|
|
32
33
|
"lint:fix": "eslint . --ext .ts --fix",
|
|
33
34
|
"format": "prettier --write \"**/*.{js,ts,json,md}\"",
|
|
@@ -42,8 +43,10 @@
|
|
|
42
43
|
"http-proxy-agent": "^7.0.2",
|
|
43
44
|
"https-proxy-agent": "^7.0.6",
|
|
44
45
|
"node-fetch": "^3.3.2",
|
|
46
|
+
"open": "^10.2.0",
|
|
45
47
|
"pino": "^9.7.0",
|
|
46
48
|
"pino-pretty": "^13.0.0",
|
|
49
|
+
"pkce-challenge": "^5.0.0",
|
|
47
50
|
"socks-proxy-agent": "^8.0.5",
|
|
48
51
|
"tough-cookie": "^5.1.2",
|
|
49
52
|
"zod-to-json-schema": "^3.23.5"
|