@zereight/mcp-gitlab 2.1.3 → 2.1.5
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 +5 -0
- package/build/config.js +1 -0
- package/build/index.js +30 -7
- package/build/oauth-proxy.js +250 -46
- package/build/schemas.js +8 -2
- package/build/test/callback-proxy-tests.js +321 -0
- package/build/test/dynamic-routing-tests.js +49 -0
- package/build/test/schema-tests.js +154 -3
- package/build/test/test-json-schema.js +148 -0
- package/build/utils/schema.js +40 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -173,12 +173,14 @@ exchanging credentials with GitLab on behalf of the client.
|
|
|
173
173
|
| `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
|
|
174
174
|
| `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
|
|
175
175
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
176
|
+
| `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL |
|
|
176
177
|
| `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
|
|
177
178
|
|
|
178
179
|
```shell
|
|
179
180
|
docker run -i --rm \
|
|
180
181
|
-e HOST=0.0.0.0 \
|
|
181
182
|
-e GITLAB_MCP_OAUTH=true \
|
|
183
|
+
-e GITLAB_OAUTH_CALLBACK_PROXY=true \
|
|
182
184
|
-e STREAMABLE_HTTP=true \
|
|
183
185
|
-e MCP_SERVER_URL=https://your-server.example.com \
|
|
184
186
|
-e GITLAB_API_URL="https://gitlab.com/api/v4" \
|
|
@@ -249,6 +251,7 @@ Commonly referenced variables:
|
|
|
249
251
|
- `GITLAB_USE_OAUTH`
|
|
250
252
|
- `REMOTE_AUTHORIZATION`
|
|
251
253
|
- `GITLAB_MCP_OAUTH`
|
|
254
|
+
- `GITLAB_OAUTH_CALLBACK_PROXY`
|
|
252
255
|
|
|
253
256
|
The reference document also covers:
|
|
254
257
|
|
|
@@ -259,6 +262,8 @@ The reference document also covers:
|
|
|
259
262
|
- transport and session variables
|
|
260
263
|
- proxy and TLS variables
|
|
261
264
|
|
|
265
|
+
For callback proxy mode details, see [GitLab MCP OAuth Callback Proxy](./docs/oauth-callback-proxy.md).
|
|
266
|
+
|
|
262
267
|
### Remote Authorization Setup (Multi-User Support)
|
|
263
268
|
|
|
264
269
|
When using `REMOTE_AUTHORIZATION=true`, the MCP server can support multiple users, each with their own GitLab token passed via HTTP headers. This is useful for:
|
package/build/config.js
CHANGED
|
@@ -56,6 +56,7 @@ export const GITLAB_OAUTH_SCOPES_RAW = getConfig("oauth-scopes", "GITLAB_OAUTH_S
|
|
|
56
56
|
export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
|
|
57
57
|
? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
|
|
58
58
|
: undefined;
|
|
59
|
+
export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
|
|
59
60
|
export const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
60
61
|
// ---------------------------------------------------------------------------
|
|
61
62
|
// Session / server settings
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
|
|
2
|
+
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -4863,9 +4863,8 @@ async function cancelPipelineJob(projectId, jobId, force) {
|
|
|
4863
4863
|
}
|
|
4864
4864
|
/**
|
|
4865
4865
|
* Get the repository tree for a project
|
|
4866
|
-
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4867
4866
|
* @param {GetRepositoryTreeOptions} options - Options for the tree
|
|
4868
|
-
* @returns
|
|
4867
|
+
* @returns Parsed tree items plus optional keyset pagination metadata.
|
|
4869
4868
|
*/
|
|
4870
4869
|
async function getRepositoryTree(options) {
|
|
4871
4870
|
options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options
|
|
@@ -4890,7 +4889,11 @@ async function getRepositoryTree(options) {
|
|
|
4890
4889
|
throw new Error(`Failed to get repository tree: ${response.statusText}`);
|
|
4891
4890
|
}
|
|
4892
4891
|
const data = await response.json();
|
|
4893
|
-
|
|
4892
|
+
const items = z.array(GitLabTreeItemSchema).parse(data);
|
|
4893
|
+
const next_page_token = response.headers.get("x-next-page-token") ||
|
|
4894
|
+
(options.pagination === "keyset" ? response.headers.get("x-next-page") : null) ||
|
|
4895
|
+
undefined;
|
|
4896
|
+
return { items, next_page_token };
|
|
4894
4897
|
}
|
|
4895
4898
|
/**
|
|
4896
4899
|
* List project milestones in a GitLab project
|
|
@@ -6583,9 +6586,18 @@ async function handleToolCall(params) {
|
|
|
6583
6586
|
}
|
|
6584
6587
|
case "get_repository_tree": {
|
|
6585
6588
|
const args = GetRepositoryTreeSchema.parse(params.arguments);
|
|
6586
|
-
const
|
|
6589
|
+
const { items, next_page_token } = await getRepositoryTree(args);
|
|
6590
|
+
const result = args.pagination === "keyset" || next_page_token
|
|
6591
|
+
? {
|
|
6592
|
+
items,
|
|
6593
|
+
...(next_page_token ? { next_page_token } : {}),
|
|
6594
|
+
pagination_note: next_page_token
|
|
6595
|
+
? "Pass next_page_token as page_token with pagination=keyset to retrieve the next page."
|
|
6596
|
+
: "No next_page_token was returned; this is the final keyset page.",
|
|
6597
|
+
}
|
|
6598
|
+
: items;
|
|
6587
6599
|
return {
|
|
6588
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
6600
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
6589
6601
|
};
|
|
6590
6602
|
}
|
|
6591
6603
|
case "list_pipelines": {
|
|
@@ -7359,7 +7371,10 @@ async function startStreamableHTTPServer() {
|
|
|
7359
7371
|
app.set("trust proxy", 1);
|
|
7360
7372
|
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
|
|
7361
7373
|
const issuerUrl = new URL(MCP_SERVER_URL);
|
|
7362
|
-
const
|
|
7374
|
+
const callbackUrl = GITLAB_OAUTH_CALLBACK_PROXY
|
|
7375
|
+
? `${issuerUrl.origin}${issuerUrl.pathname.replace(/\/$/, "")}/callback`
|
|
7376
|
+
: undefined;
|
|
7377
|
+
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl);
|
|
7363
7378
|
const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
|
|
7364
7379
|
// When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
|
|
7365
7380
|
// advertises root-level endpoints. Override to use path-prefixed endpoints.
|
|
@@ -7412,6 +7427,14 @@ async function startStreamableHTTPServer() {
|
|
|
7412
7427
|
}));
|
|
7413
7428
|
// Expose provider so the /mcp route middleware can reference it
|
|
7414
7429
|
app._mcpOAuthProvider = oauthProvider;
|
|
7430
|
+
// Mount /callback route for callback proxy mode
|
|
7431
|
+
if (GITLAB_OAUTH_CALLBACK_PROXY) {
|
|
7432
|
+
const callbackPath = `${issuerUrl.pathname.replace(/\/$/, "")}/callback`;
|
|
7433
|
+
app.get(callbackPath, (req, res, next) => {
|
|
7434
|
+
oauthProvider.handleCallback(req, res).catch(next);
|
|
7435
|
+
});
|
|
7436
|
+
logger.info(`Callback proxy mode enabled — ${callbackPath} route mounted`);
|
|
7437
|
+
}
|
|
7415
7438
|
}
|
|
7416
7439
|
// Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
|
|
7417
7440
|
// Unauthenticated requests receive 401 + WWW-Authenticate header, which is
|
package/build/oauth-proxy.js
CHANGED
|
@@ -13,7 +13,26 @@
|
|
|
13
13
|
* and handles DCR locally — each MCP client gets a unique virtual client_id
|
|
14
14
|
* mapped to the real GitLab app.
|
|
15
15
|
*
|
|
16
|
-
* ### Flow
|
|
16
|
+
* ### Flow (callback proxy mode — GITLAB_OAUTH_CALLBACK_PROXY=true)
|
|
17
|
+
*
|
|
18
|
+
* When callback proxy mode is enabled, the MCP server acts as a full OAuth
|
|
19
|
+
* intermediary, similar to the Atlassian MCP's OAuthProxy pattern. Only ONE
|
|
20
|
+
* fixed callback URL needs to be registered with GitLab, regardless of how
|
|
21
|
+
* many MCP clients connect.
|
|
22
|
+
*
|
|
23
|
+
* 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally
|
|
24
|
+
* and returns a virtual client_id.
|
|
25
|
+
* 2. MCP client redirects to /authorize — proxy stores the client's original
|
|
26
|
+
* redirect_uri and state, generates its own PKCE pair, then redirects to
|
|
27
|
+
* GitLab using the MCP server's fixed /callback URL as redirect_uri.
|
|
28
|
+
* 3. User authorizes on GitLab — GitLab redirects to the MCP server's /callback.
|
|
29
|
+
* 4. /callback handler exchanges the code with GitLab for tokens, stores them
|
|
30
|
+
* server-side, generates a new proxy auth code, and redirects to the client's
|
|
31
|
+
* original redirect_uri with the proxy code.
|
|
32
|
+
* 5. MCP client calls POST /token with the proxy code — proxy returns the
|
|
33
|
+
* stored GitLab tokens.
|
|
34
|
+
*
|
|
35
|
+
* ### Flow (passthrough mode — default)
|
|
17
36
|
*
|
|
18
37
|
* 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally
|
|
19
38
|
* and returns a virtual client_id.
|
|
@@ -27,62 +46,81 @@
|
|
|
27
46
|
*/
|
|
28
47
|
import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
29
48
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
30
|
-
import { randomUUID } from "node:crypto";
|
|
49
|
+
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
31
50
|
import { pino } from "pino";
|
|
32
51
|
const logger = pino({ name: "gitlab-mcp-oauth-proxy" });
|
|
33
52
|
// ---------------------------------------------------------------------------
|
|
34
|
-
//
|
|
53
|
+
// GitLab OAuth Server Provider
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* Minimum GitLab scopes required for the MCP server to function.
|
|
57
|
+
* Injected into the authorization request when the client does not request them.
|
|
58
|
+
*/
|
|
59
|
+
const REQUIRED_GITLAB_SCOPES_RW = ["api"];
|
|
60
|
+
const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Callback proxy mode — pending auth transactions
|
|
35
63
|
// ---------------------------------------------------------------------------
|
|
64
|
+
const PENDING_AUTH_MAX_SIZE = 1000;
|
|
65
|
+
const PENDING_AUTH_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
36
66
|
const CLIENT_CACHE_MAX_SIZE = 1000;
|
|
37
|
-
class
|
|
67
|
+
class BoundedLRUMap {
|
|
38
68
|
_map = new Map();
|
|
39
69
|
_maxSize;
|
|
40
70
|
constructor(maxSize) {
|
|
41
71
|
this._maxSize = maxSize;
|
|
42
72
|
}
|
|
43
|
-
get(
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
this._map.delete(
|
|
47
|
-
this._map.set(
|
|
73
|
+
get(key) {
|
|
74
|
+
const v = this._map.get(key);
|
|
75
|
+
if (v !== undefined) {
|
|
76
|
+
this._map.delete(key);
|
|
77
|
+
this._map.set(key, v);
|
|
48
78
|
}
|
|
49
|
-
return
|
|
79
|
+
return v;
|
|
50
80
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
/** Get and remove in one operation — for one-time-use entries. */
|
|
82
|
+
getAndDelete(key) {
|
|
83
|
+
const v = this._map.get(key);
|
|
84
|
+
if (v !== undefined)
|
|
85
|
+
this._map.delete(key);
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
88
|
+
set(key, value) {
|
|
89
|
+
if (this._map.has(key))
|
|
90
|
+
this._map.delete(key);
|
|
55
91
|
else if (this._map.size >= this._maxSize) {
|
|
56
92
|
const lruKey = this._map.keys().next().value;
|
|
57
93
|
if (lruKey !== undefined)
|
|
58
94
|
this._map.delete(lruKey);
|
|
59
95
|
}
|
|
60
|
-
this._map.set(
|
|
96
|
+
this._map.set(key, value);
|
|
97
|
+
}
|
|
98
|
+
delete(key) {
|
|
99
|
+
return this._map.delete(key);
|
|
61
100
|
}
|
|
62
101
|
get size() {
|
|
63
102
|
return this._map.size;
|
|
64
103
|
}
|
|
65
104
|
}
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// GitLab OAuth Server Provider
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
/**
|
|
70
|
-
* Minimum GitLab scopes required for the MCP server to function.
|
|
71
|
-
* Injected into the authorization request when the client does not request them.
|
|
72
|
-
*/
|
|
73
|
-
const REQUIRED_GITLAB_SCOPES_RW = ["api"];
|
|
74
|
-
const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
|
|
75
105
|
class GitLabOAuthServerProvider {
|
|
76
106
|
/**
|
|
77
|
-
* Tell the SDK not to validate PKCE locally
|
|
107
|
+
* Tell the SDK not to validate PKCE locally.
|
|
108
|
+
* - Passthrough mode: GitLab handles PKCE validation.
|
|
109
|
+
* - Callback proxy mode: we verify the client's PKCE manually in
|
|
110
|
+
* exchangeAuthorizationCode() after looking up stored tokens.
|
|
78
111
|
*/
|
|
79
112
|
skipLocalPkceValidation = true;
|
|
80
113
|
_gitlabBaseUrl;
|
|
81
114
|
_gitlabAppId;
|
|
82
115
|
_resourceName;
|
|
83
116
|
_requiredScopes;
|
|
84
|
-
_clientCache = new
|
|
85
|
-
|
|
117
|
+
_clientCache = new BoundedLRUMap(CLIENT_CACHE_MAX_SIZE);
|
|
118
|
+
// Callback proxy mode fields
|
|
119
|
+
_callbackProxyEnabled;
|
|
120
|
+
_callbackUrl;
|
|
121
|
+
_pendingAuth = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
122
|
+
_storedTokens = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
123
|
+
constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
|
|
86
124
|
this._gitlabBaseUrl = gitlabBaseUrl;
|
|
87
125
|
this._gitlabAppId = gitlabAppId;
|
|
88
126
|
this._resourceName = resourceName;
|
|
@@ -92,6 +130,14 @@ class GitLabOAuthServerProvider {
|
|
|
92
130
|
: readOnly
|
|
93
131
|
? REQUIRED_GITLAB_SCOPES_RO
|
|
94
132
|
: REQUIRED_GITLAB_SCOPES_RW;
|
|
133
|
+
this._callbackProxyEnabled = callbackProxyEnabled;
|
|
134
|
+
this._callbackUrl = callbackUrl;
|
|
135
|
+
if (callbackProxyEnabled && !callbackUrl) {
|
|
136
|
+
throw new Error("callbackUrl is required when callbackProxyEnabled is true");
|
|
137
|
+
}
|
|
138
|
+
if (callbackProxyEnabled) {
|
|
139
|
+
logger.info(`Callback proxy mode enabled — fixed callback URL: ${callbackUrl}`);
|
|
140
|
+
}
|
|
95
141
|
}
|
|
96
142
|
// ---- Client store (local DCR) ------------------------------------------
|
|
97
143
|
get clientsStore() {
|
|
@@ -130,7 +176,7 @@ class GitLabOAuthServerProvider {
|
|
|
130
176
|
};
|
|
131
177
|
}
|
|
132
178
|
// ---- Authorize ---------------------------------------------------------
|
|
133
|
-
async authorize(
|
|
179
|
+
async authorize(client, params, res) {
|
|
134
180
|
const scopes = params.scopes ?? [];
|
|
135
181
|
const hasRequired = this._requiredScopes.some((s) => scopes.includes(s));
|
|
136
182
|
const effectiveScopes = hasRequired
|
|
@@ -138,21 +184,57 @@ class GitLabOAuthServerProvider {
|
|
|
138
184
|
: [...new Set([...scopes, ...this._requiredScopes])];
|
|
139
185
|
// Build the GitLab authorize URL with the REAL app client_id
|
|
140
186
|
const targetUrl = new URL(`${this._gitlabBaseUrl}/oauth/authorize`);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
if (this._callbackProxyEnabled) {
|
|
188
|
+
// --- Callback proxy mode ---
|
|
189
|
+
// Generate a proxy PKCE pair (MCP server ↔ GitLab)
|
|
190
|
+
const proxyCodeVerifier = randomBytes(32).toString("base64url");
|
|
191
|
+
const proxyCodeChallenge = createHash("sha256")
|
|
192
|
+
.update(proxyCodeVerifier)
|
|
193
|
+
.digest("base64url");
|
|
194
|
+
// Use a unique state to correlate the callback
|
|
195
|
+
const proxyState = randomUUID();
|
|
196
|
+
// Store the client's original params so /callback can redirect back
|
|
197
|
+
this._pendingAuth.set(proxyState, {
|
|
198
|
+
clientId: client.client_id,
|
|
199
|
+
clientRedirectUri: params.redirectUri,
|
|
200
|
+
clientState: params.state,
|
|
201
|
+
clientCodeChallenge: params.codeChallenge,
|
|
202
|
+
proxyCodeVerifier,
|
|
203
|
+
createdAt: Date.now(),
|
|
204
|
+
});
|
|
205
|
+
const searchParams = new URLSearchParams({
|
|
206
|
+
client_id: this._gitlabAppId,
|
|
207
|
+
response_type: "code",
|
|
208
|
+
redirect_uri: this._callbackUrl,
|
|
209
|
+
code_challenge: proxyCodeChallenge,
|
|
210
|
+
code_challenge_method: "S256",
|
|
211
|
+
state: proxyState,
|
|
212
|
+
});
|
|
213
|
+
if (effectiveScopes.length)
|
|
214
|
+
searchParams.set("scope", effectiveScopes.join(" "));
|
|
215
|
+
if (params.resource)
|
|
216
|
+
searchParams.set("resource", params.resource.href);
|
|
217
|
+
targetUrl.search = searchParams.toString();
|
|
218
|
+
logger.info(`authorize (callback proxy): redirecting to GitLab with fixed callback URL (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
// --- Passthrough mode (original behavior) ---
|
|
222
|
+
const searchParams = new URLSearchParams({
|
|
223
|
+
client_id: this._gitlabAppId,
|
|
224
|
+
response_type: "code",
|
|
225
|
+
redirect_uri: params.redirectUri,
|
|
226
|
+
code_challenge: params.codeChallenge,
|
|
227
|
+
code_challenge_method: "S256",
|
|
228
|
+
});
|
|
229
|
+
if (params.state)
|
|
230
|
+
searchParams.set("state", params.state);
|
|
231
|
+
if (effectiveScopes.length)
|
|
232
|
+
searchParams.set("scope", effectiveScopes.join(" "));
|
|
233
|
+
if (params.resource)
|
|
234
|
+
searchParams.set("resource", params.resource.href);
|
|
235
|
+
targetUrl.search = searchParams.toString();
|
|
236
|
+
logger.info(`authorize: redirecting to GitLab (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
|
|
237
|
+
}
|
|
156
238
|
res.redirect(targetUrl.toString());
|
|
157
239
|
}
|
|
158
240
|
// ---- PKCE challenge (delegated to GitLab) ------------------------------
|
|
@@ -160,7 +242,45 @@ class GitLabOAuthServerProvider {
|
|
|
160
242
|
return "";
|
|
161
243
|
}
|
|
162
244
|
// ---- Token exchange ----------------------------------------------------
|
|
163
|
-
async exchangeAuthorizationCode(
|
|
245
|
+
async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
|
|
246
|
+
if (this._callbackProxyEnabled) {
|
|
247
|
+
// --- Callback proxy mode ---
|
|
248
|
+
// The authorizationCode is a proxy code we generated in handleCallback().
|
|
249
|
+
// Look up the stored tokens by proxy code.
|
|
250
|
+
const entry = this._storedTokens.get(authorizationCode);
|
|
251
|
+
if (!entry) {
|
|
252
|
+
throw new ServerError("Invalid or expired authorization code");
|
|
253
|
+
}
|
|
254
|
+
// Check TTL before consuming — expired entries can't be retried anyway,
|
|
255
|
+
// but we give a specific error so the client knows to restart the flow.
|
|
256
|
+
if (Date.now() - entry.createdAt > PENDING_AUTH_TTL_MS) {
|
|
257
|
+
this._storedTokens.delete(authorizationCode);
|
|
258
|
+
throw new ServerError("Authorization code expired — please restart the OAuth flow");
|
|
259
|
+
}
|
|
260
|
+
// One-time use: delete after validation
|
|
261
|
+
this._storedTokens.delete(authorizationCode);
|
|
262
|
+
// Bind the proxy code to the client and redirect_uri that initiated
|
|
263
|
+
// /authorize, preserving the normal OAuth authorization-code invariant.
|
|
264
|
+
if (client.client_id !== entry.clientId) {
|
|
265
|
+
throw new ServerError("Invalid client for authorization code");
|
|
266
|
+
}
|
|
267
|
+
if (redirectUri !== entry.clientRedirectUri) {
|
|
268
|
+
throw new ServerError("Invalid redirect_uri for authorization code");
|
|
269
|
+
}
|
|
270
|
+
// Verify client PKCE: the client's code_verifier must match the
|
|
271
|
+
// code_challenge stored during /authorize.
|
|
272
|
+
if (entry.clientCodeChallenge) {
|
|
273
|
+
if (!codeVerifier) {
|
|
274
|
+
throw new ServerError("PKCE code_verifier is required");
|
|
275
|
+
}
|
|
276
|
+
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
277
|
+
if (computed !== entry.clientCodeChallenge) {
|
|
278
|
+
throw new ServerError("PKCE verification failed");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return entry.tokens;
|
|
282
|
+
}
|
|
283
|
+
// --- Passthrough mode (original behavior) ---
|
|
164
284
|
const params = new URLSearchParams({
|
|
165
285
|
grant_type: "authorization_code",
|
|
166
286
|
client_id: this._gitlabAppId,
|
|
@@ -227,6 +347,86 @@ class GitLabOAuthServerProvider {
|
|
|
227
347
|
: undefined,
|
|
228
348
|
};
|
|
229
349
|
}
|
|
350
|
+
// ---- Callback handler (callback proxy mode) ----------------------------
|
|
351
|
+
/**
|
|
352
|
+
* Handle the OAuth callback from GitLab.
|
|
353
|
+
* Exchanges the auth code for tokens, stores them, generates a proxy code,
|
|
354
|
+
* and redirects to the MCP client's original callback URL.
|
|
355
|
+
*
|
|
356
|
+
* Mount this as GET /callback in the Express app.
|
|
357
|
+
*/
|
|
358
|
+
async handleCallback(req, res) {
|
|
359
|
+
if (!this._callbackProxyEnabled) {
|
|
360
|
+
res.status(404).send("Callback proxy mode is not enabled");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const code = req.query.code;
|
|
364
|
+
const state = req.query.state;
|
|
365
|
+
const error = req.query.error;
|
|
366
|
+
if (error) {
|
|
367
|
+
logger.error(`GitLab OAuth error: ${error} — ${req.query.error_description ?? "(no description)"}`);
|
|
368
|
+
res.status(400).send("Authorization failed");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!code || !state) {
|
|
372
|
+
res.status(400).send("Missing code or state parameter");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// Look up the pending auth transaction
|
|
376
|
+
const pending = this._pendingAuth.getAndDelete(state);
|
|
377
|
+
if (!pending) {
|
|
378
|
+
res.status(400).send("Unknown or expired state parameter");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Check TTL
|
|
382
|
+
if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) {
|
|
383
|
+
res.status(400).send("Authorization request expired");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Exchange the GitLab auth code for tokens using the proxy's PKCE verifier
|
|
387
|
+
try {
|
|
388
|
+
const tokenParams = new URLSearchParams({
|
|
389
|
+
grant_type: "authorization_code",
|
|
390
|
+
client_id: this._gitlabAppId,
|
|
391
|
+
code,
|
|
392
|
+
redirect_uri: this._callbackUrl,
|
|
393
|
+
code_verifier: pending.proxyCodeVerifier,
|
|
394
|
+
});
|
|
395
|
+
const tokenResponse = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
396
|
+
method: "POST",
|
|
397
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
398
|
+
body: tokenParams.toString(),
|
|
399
|
+
});
|
|
400
|
+
if (!tokenResponse.ok) {
|
|
401
|
+
const body = await tokenResponse.text();
|
|
402
|
+
logger.error(`Callback token exchange failed (${tokenResponse.status}): ${body}`);
|
|
403
|
+
res.status(502).send("Token exchange with GitLab failed");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const tokens = OAuthTokensSchema.parse(await tokenResponse.json());
|
|
407
|
+
// Generate a proxy auth code for the MCP client
|
|
408
|
+
const proxyCode = randomUUID();
|
|
409
|
+
this._storedTokens.set(proxyCode, {
|
|
410
|
+
tokens,
|
|
411
|
+
clientId: pending.clientId,
|
|
412
|
+
clientCodeChallenge: pending.clientCodeChallenge,
|
|
413
|
+
clientRedirectUri: pending.clientRedirectUri,
|
|
414
|
+
createdAt: Date.now(),
|
|
415
|
+
});
|
|
416
|
+
// Redirect to the MCP client's original callback URL
|
|
417
|
+
const clientCallback = new URL(pending.clientRedirectUri);
|
|
418
|
+
clientCallback.searchParams.set("code", proxyCode);
|
|
419
|
+
if (pending.clientState) {
|
|
420
|
+
clientCallback.searchParams.set("state", pending.clientState);
|
|
421
|
+
}
|
|
422
|
+
logger.info(`callback: exchanged code with GitLab, redirecting to client callback`);
|
|
423
|
+
res.redirect(clientCallback.toString());
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
logger.error({ err }, "Callback handler error");
|
|
427
|
+
res.status(500).send("Internal error during token exchange");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
230
430
|
// ---- Revoke token ------------------------------------------------------
|
|
231
431
|
async revokeToken(_client, request) {
|
|
232
432
|
const params = new URLSearchParams({
|
|
@@ -258,7 +458,11 @@ class GitLabOAuthServerProvider {
|
|
|
258
458
|
* @param resourceName Human-readable name shown on the GitLab consent screen.
|
|
259
459
|
* @param readOnly When true and customScopes is not set, restricts to read_api scope.
|
|
260
460
|
* @param customScopes Explicit list of GitLab scopes to require. Overrides readOnly when set.
|
|
461
|
+
* @param callbackProxyEnabled When true, the MCP server handles the OAuth callback internally.
|
|
462
|
+
* Only ONE fixed callback URL needs to be registered with GitLab.
|
|
463
|
+
* @param callbackUrl The fixed callback URL (e.g. https://mcp.example.com/callback).
|
|
464
|
+
* Required when callbackProxyEnabled is true.
|
|
261
465
|
*/
|
|
262
|
-
export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes) {
|
|
263
|
-
return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes);
|
|
466
|
+
export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
|
|
467
|
+
return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled, callbackUrl);
|
|
264
468
|
}
|
package/build/schemas.js
CHANGED
|
@@ -634,8 +634,14 @@ export const GetRepositoryTreeSchema = z.object({
|
|
|
634
634
|
.describe("The name of a repository branch or tag. Defaults to the default branch."),
|
|
635
635
|
recursive: z.coerce.boolean().optional().describe("Boolean value to get a recursive tree"),
|
|
636
636
|
per_page: z.coerce.number().optional().describe("Number of results to show per page"),
|
|
637
|
-
page_token: z
|
|
638
|
-
|
|
637
|
+
page_token: z
|
|
638
|
+
.string()
|
|
639
|
+
.optional()
|
|
640
|
+
.describe("Token for keyset pagination. Use the next_page_token value returned in the previous response to retrieve the next page."),
|
|
641
|
+
pagination: z
|
|
642
|
+
.string()
|
|
643
|
+
.optional()
|
|
644
|
+
.describe("Pagination method. Use 'keyset' for keyset-based pagination (required for repositories with many files). Non-keyset calls keep the legacy array response for backward compatibility; that legacy response shape is deprecated and may be removed in a future major release. Keyset calls return a structured response with items and next_page_token when more pages are available."),
|
|
639
645
|
});
|
|
640
646
|
export const GitLabTreeSchema = z.object({
|
|
641
647
|
id: z.string(), // Changed from sha to match GitLab API
|