@zereight/mcp-gitlab 2.1.4 → 2.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +442 -0
- package/README.md +12 -0
- package/README.zh-CN.md +442 -0
- package/build/config.js +65 -2
- package/build/index.js +348 -5
- package/build/oauth-proxy.js +182 -47
- package/build/stateless/client-id.js +68 -0
- package/build/stateless/codec.js +205 -0
- package/build/stateless/errors.js +24 -0
- package/build/stateless/index.js +14 -0
- package/build/stateless/pending-auth.js +65 -0
- package/build/stateless/secret.js +98 -0
- package/build/stateless/session-id.js +68 -0
- package/build/stateless/stored-tokens.js +66 -0
- package/build/stateless/types.js +18 -0
- package/build/test/schema-tests.js +81 -3
- package/build/test/stateless/callback-proxy.test.js +393 -0
- package/build/test/stateless/client-id.test.js +176 -0
- package/build/test/stateless/codec.test.js +328 -0
- package/build/test/stateless/config-ttl.test.js +149 -0
- package/build/test/stateless/session-id-integration.test.js +675 -0
- package/build/test/stateless/session-id.test.js +131 -0
- package/build/test/test-json-schema.js +148 -0
- package/build/utils/schema.js +40 -6
- package/package.json +4 -3
package/build/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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_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";
|
|
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, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, 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
|
+
import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./stateless/index.js";
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -37,6 +38,32 @@ import { randomUUID } from "node:crypto";
|
|
|
37
38
|
import { pino } from "pino";
|
|
38
39
|
const logger = pino({
|
|
39
40
|
level: process.env.LOG_LEVEL || "info",
|
|
41
|
+
// Redact sensitive values that must never appear in logs. Covers both
|
|
42
|
+
// typical auth-context property names and common HTTP header-bag shapes
|
|
43
|
+
// that tool/fetch wrappers may include when logging errors.
|
|
44
|
+
redact: {
|
|
45
|
+
paths: [
|
|
46
|
+
"token",
|
|
47
|
+
"*.token",
|
|
48
|
+
"ctx.token",
|
|
49
|
+
"context.token",
|
|
50
|
+
"authData.token",
|
|
51
|
+
"auth.token",
|
|
52
|
+
"headers.authorization",
|
|
53
|
+
"headers.Authorization",
|
|
54
|
+
'headers["private-token"]',
|
|
55
|
+
'headers["Private-Token"]',
|
|
56
|
+
'headers["job-token"]',
|
|
57
|
+
'headers["JOB-TOKEN"]',
|
|
58
|
+
'headers["mcp-session-id"]',
|
|
59
|
+
'headers["Mcp-Session-Id"]',
|
|
60
|
+
"sessionId",
|
|
61
|
+
"*.sessionId",
|
|
62
|
+
"ctx.sessionId",
|
|
63
|
+
"context.sessionId",
|
|
64
|
+
],
|
|
65
|
+
censor: "[REDACTED]",
|
|
66
|
+
},
|
|
40
67
|
transport: {
|
|
41
68
|
target: "pino-pretty",
|
|
42
69
|
options: {
|
|
@@ -412,6 +439,97 @@ function validateConfiguration() {
|
|
|
412
439
|
}
|
|
413
440
|
let OAUTH_ACCESS_TOKEN = null;
|
|
414
441
|
let oauthClient = null;
|
|
442
|
+
/**
|
|
443
|
+
* Produce a safe short form of a session id for logs. Legacy UUIDs pass
|
|
444
|
+
* through; stateless sealed sids are truncated to the "v1.sid." prefix
|
|
445
|
+
* plus a handful of bytes of the ciphertext so operators can correlate
|
|
446
|
+
* flows without the log line carrying the sealed bearer token.
|
|
447
|
+
*/
|
|
448
|
+
function redactSessionIdForLog(sid) {
|
|
449
|
+
if (!sid)
|
|
450
|
+
return "<none>";
|
|
451
|
+
if (sid.startsWith("v1.sid."))
|
|
452
|
+
return "v1.sid.<redacted>";
|
|
453
|
+
// UUIDs / other formats are low-sensitivity; show first 8 chars.
|
|
454
|
+
return sid.length > 8 ? `${sid.slice(0, 8)}…` : sid;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Detect whether an MCP JSON-RPC request body represents an "initialize"
|
|
458
|
+
* request. Accepts both single-message and batch forms. Returns false on any
|
|
459
|
+
* unexpected shape — callers treat an ambiguous body as non-init, which is
|
|
460
|
+
* the safer default (it means the SDK will fail loudly rather than silently
|
|
461
|
+
* spawning a new session).
|
|
462
|
+
*/
|
|
463
|
+
function isInitializationRequestBody(body) {
|
|
464
|
+
if (!body)
|
|
465
|
+
return false;
|
|
466
|
+
const isInitObj = (m) => typeof m === "object" &&
|
|
467
|
+
m !== null &&
|
|
468
|
+
m.method === "initialize";
|
|
469
|
+
if (Array.isArray(body))
|
|
470
|
+
return body.some(isInitObj);
|
|
471
|
+
return isInitObj(body);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Normalize an `Mcp-Session-Id` header value.
|
|
475
|
+
*
|
|
476
|
+
* Node's HTTP types allow any request header to surface as `string[]` when
|
|
477
|
+
* the client sends it more than once. Casting to `string` and calling
|
|
478
|
+
* `.startsWith()` on an array throws `TypeError: startsWith is not a
|
|
479
|
+
* function`, which Express converts to a 500 — silently turning malformed
|
|
480
|
+
* requests into server errors and breaking the 401/404 semantics we
|
|
481
|
+
* carefully distinguish in stateless mode. A duplicated `Mcp-Session-Id` is
|
|
482
|
+
* also ill-formed at the protocol level: there is no well-defined way to
|
|
483
|
+
* pick between two values, so we reject arrays rather than guess.
|
|
484
|
+
*
|
|
485
|
+
* Empty-string is normalized to `undefined` so call sites can use truthy
|
|
486
|
+
* checks and `?? undefined`-style fallbacks uniformly.
|
|
487
|
+
*
|
|
488
|
+
* Exported for unit tests; otherwise used only by the /mcp handlers below.
|
|
489
|
+
*/
|
|
490
|
+
export function readMcpSessionIdHeader(req) {
|
|
491
|
+
const raw = req.headers["mcp-session-id"];
|
|
492
|
+
if (typeof raw !== "string")
|
|
493
|
+
return undefined;
|
|
494
|
+
return raw.length > 0 ? raw : undefined;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Loaded once at startup. Null when OAUTH_STATELESS_MODE is disabled.
|
|
498
|
+
* When set, the OAuth provider and (later phases) the Mcp-Session-Id path
|
|
499
|
+
* switch to signed/sealed opaque values instead of per-pod in-memory caches.
|
|
500
|
+
*/
|
|
501
|
+
let STATELESS_MATERIAL = null;
|
|
502
|
+
try {
|
|
503
|
+
// Drive enablement from the already-resolved config flag so the CLI flag
|
|
504
|
+
// (--oauth-stateless-mode) is honored. Re-reading env.OAUTH_STATELESS_MODE
|
|
505
|
+
// inside the loader would silently ignore the CLI flag and leave
|
|
506
|
+
// STATELESS_MATERIAL null, falling back to per-pod state.
|
|
507
|
+
STATELESS_MATERIAL = loadKeyMaterialFromEnv(OAUTH_STATELESS_MODE);
|
|
508
|
+
if (OAUTH_STATELESS_MODE && STATELESS_MATERIAL) {
|
|
509
|
+
// Avoid logging anything that could leak key length / entropy details.
|
|
510
|
+
// Keep the message aligned with similar startup banners.
|
|
511
|
+
// eslint-disable-next-line no-console -- startup banner parity
|
|
512
|
+
console.error("[gitlab-mcp] stateless OAuth mode enabled");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
// eslint-disable-next-line no-console -- startup failure must be visible
|
|
517
|
+
console.error(`[gitlab-mcp] failed to load stateless secret: ${err.message}`);
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* True when this request is a candidate for the stateless sid-auth path:
|
|
522
|
+
* stateless mode is enabled, key material loaded, and the client sent an
|
|
523
|
+
* Mcp-Session-Id header. We deliberately key off *presence* (not validity)
|
|
524
|
+
* so malformed / expired / legacy sids still reach handleStatelessMcpRequest
|
|
525
|
+
* and get the intended 404 Session not found — rather than being masked by
|
|
526
|
+
* a 401 from the OAuth bearer middleware.
|
|
527
|
+
*/
|
|
528
|
+
export function hasStatelessSessionId(req) {
|
|
529
|
+
return Boolean(OAUTH_STATELESS_MODE &&
|
|
530
|
+
STATELESS_MATERIAL &&
|
|
531
|
+
readMcpSessionIdHeader(req));
|
|
532
|
+
}
|
|
415
533
|
/**
|
|
416
534
|
* Ensure the OAuth token is valid before making an API call.
|
|
417
535
|
* Refreshes the token lazily (only when a tool is actually called).
|
|
@@ -7238,6 +7356,12 @@ async function startStreamableHTTPServer() {
|
|
|
7238
7356
|
requestsProcessed: 0,
|
|
7239
7357
|
rejectedByRateLimit: 0,
|
|
7240
7358
|
rejectedByCapacity: 0,
|
|
7359
|
+
// Stateless-mode counters. Only non-zero when OAUTH_STATELESS_MODE=true.
|
|
7360
|
+
statelessRequests: 0,
|
|
7361
|
+
statelessAuthFromHeader: 0, // fresh Authorization/Private-Token/JOB-TOKEN present
|
|
7362
|
+
statelessAuthFromSealedSid: 0, // auth reconstructed from sealed Mcp-Session-Id
|
|
7363
|
+
statelessAuthFailures: 0, // neither source yielded usable auth
|
|
7364
|
+
statelessSidRotated: 0, // minted a new sid because fresh auth was present
|
|
7241
7365
|
};
|
|
7242
7366
|
// Rate limiting per session
|
|
7243
7367
|
const sessionRequestCounts = {};
|
|
@@ -7362,6 +7486,189 @@ async function startStreamableHTTPServer() {
|
|
|
7362
7486
|
delete authBySession[sessionId];
|
|
7363
7487
|
clearAuthTimeout(sessionId);
|
|
7364
7488
|
};
|
|
7489
|
+
/**
|
|
7490
|
+
* Stateless-mode handler for /mcp POSTs.
|
|
7491
|
+
*
|
|
7492
|
+
* The MCP Streamable HTTP SDK can run in "stateless mode" when
|
|
7493
|
+
* `sessionIdGenerator` is undefined — it creates no session state and
|
|
7494
|
+
* short-circuits session validation. We exploit that by driving the
|
|
7495
|
+
* session identity ourselves via an AEAD-sealed `Mcp-Session-Id`:
|
|
7496
|
+
*
|
|
7497
|
+
* 1. Every request gets a freshly-instantiated transport (SDK-stateless).
|
|
7498
|
+
* 2. The caller's auth is derived from:
|
|
7499
|
+
* - live request headers (preferred; handles OAuth token refresh),
|
|
7500
|
+
* - or the sealed Mcp-Session-Id (for requests that omit headers).
|
|
7501
|
+
* 3. We assign `transport.sessionId = <sealed>` so the SDK emits the
|
|
7502
|
+
* Mcp-Session-Id response header for the client to echo back.
|
|
7503
|
+
* 4. The AsyncLocalStorage auth context is populated from the derived
|
|
7504
|
+
* auth, bypassing authBySession / authTimeouts entirely.
|
|
7505
|
+
*
|
|
7506
|
+
* Rate limiting is explicitly disabled in stateless mode (per-pod counters
|
|
7507
|
+
* would yield a loose global bound — operators can rate-limit upstream).
|
|
7508
|
+
*/
|
|
7509
|
+
const handleStatelessMcpRequest = async (req, res, material, sessionTtlSeconds) => {
|
|
7510
|
+
metrics.statelessRequests++;
|
|
7511
|
+
// Step 1: derive the effective auth for this request.
|
|
7512
|
+
// Priority: live headers > sealed sid. This lets clients refresh OAuth
|
|
7513
|
+
// tokens without re-initializing their MCP session.
|
|
7514
|
+
const headerAuth = parseAuthHeaders(req);
|
|
7515
|
+
// In GITLAB_MCP_OAUTH mode, req.auth may be populated by requireBearerAuth.
|
|
7516
|
+
// Use it when headerAuth is null (no Private-Token / JOB-TOKEN / Authorization).
|
|
7517
|
+
let effective = null;
|
|
7518
|
+
let freshAuthPresent = false;
|
|
7519
|
+
if (headerAuth) {
|
|
7520
|
+
effective = {
|
|
7521
|
+
header: headerAuth.header,
|
|
7522
|
+
token: headerAuth.token,
|
|
7523
|
+
apiUrl: headerAuth.apiUrl,
|
|
7524
|
+
};
|
|
7525
|
+
freshAuthPresent = true;
|
|
7526
|
+
}
|
|
7527
|
+
else if (GITLAB_MCP_OAUTH) {
|
|
7528
|
+
const authInfo = req.auth;
|
|
7529
|
+
if (authInfo?.token) {
|
|
7530
|
+
effective = {
|
|
7531
|
+
header: "Authorization",
|
|
7532
|
+
token: authInfo.token,
|
|
7533
|
+
apiUrl: GITLAB_API_URL,
|
|
7534
|
+
};
|
|
7535
|
+
freshAuthPresent = true;
|
|
7536
|
+
}
|
|
7537
|
+
}
|
|
7538
|
+
if (freshAuthPresent)
|
|
7539
|
+
metrics.statelessAuthFromHeader++;
|
|
7540
|
+
// Fall back to the sealed sid when no live headers are present. Track
|
|
7541
|
+
// whether a sid was presented but rejected (expired / tampered / wrong
|
|
7542
|
+
// key / malformed) so we can return 404 below — the MCP Streamable HTTP
|
|
7543
|
+
// contract is that session-bound requests get 404 on a terminated session,
|
|
7544
|
+
// which tells the client to re-initialize. Returning 401 here would
|
|
7545
|
+
// instead trigger the client's auth-failure path and break automatic
|
|
7546
|
+
// recovery after inactivity TTL expiry.
|
|
7547
|
+
const incomingSid = readMcpSessionIdHeader(req);
|
|
7548
|
+
let sidPresentedButInvalid = false;
|
|
7549
|
+
if (!effective && incomingSid) {
|
|
7550
|
+
if (looksLikeStatelessSessionId(incomingSid)) {
|
|
7551
|
+
const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
|
|
7552
|
+
if (opened) {
|
|
7553
|
+
effective = { header: opened.h, token: opened.t, apiUrl: opened.u };
|
|
7554
|
+
metrics.statelessAuthFromSealedSid++;
|
|
7555
|
+
}
|
|
7556
|
+
else {
|
|
7557
|
+
sidPresentedButInvalid = true;
|
|
7558
|
+
}
|
|
7559
|
+
}
|
|
7560
|
+
else {
|
|
7561
|
+
// Non-stateless-shaped sid (e.g. a UUID from a prior stateful run, or
|
|
7562
|
+
// garbage). From the caller's perspective the session is unknown — we
|
|
7563
|
+
// surface that as "session ended" rather than as an auth problem.
|
|
7564
|
+
sidPresentedButInvalid = true;
|
|
7565
|
+
}
|
|
7566
|
+
}
|
|
7567
|
+
if (!effective) {
|
|
7568
|
+
metrics.authFailures++;
|
|
7569
|
+
metrics.statelessAuthFailures++;
|
|
7570
|
+
if (sidPresentedButInvalid) {
|
|
7571
|
+
// Per MCP Streamable HTTP: a 404 on a session-bound request signals
|
|
7572
|
+
// "session ended, re-initialize". The inactivity TTL expiring looks
|
|
7573
|
+
// identical to a session the server no longer recognizes — in both
|
|
7574
|
+
// cases the client should start a fresh initialize handshake.
|
|
7575
|
+
res.status(404).json({
|
|
7576
|
+
error: "Session not found",
|
|
7577
|
+
message: "Stateless mode: Mcp-Session-Id is expired, invalid, or from a " +
|
|
7578
|
+
"different key. Re-initialize to obtain a new session id.",
|
|
7579
|
+
});
|
|
7580
|
+
return;
|
|
7581
|
+
}
|
|
7582
|
+
res.status(401).json({
|
|
7583
|
+
error: "Authentication required",
|
|
7584
|
+
message: "Stateless mode: provide Private-Token, JOB-TOKEN, or Authorization " +
|
|
7585
|
+
"header, or a valid Mcp-Session-Id from a previous response.",
|
|
7586
|
+
});
|
|
7587
|
+
return;
|
|
7588
|
+
}
|
|
7589
|
+
// Step 2: detect whether this is the initialization request. The MCP SDK
|
|
7590
|
+
// only emits an Mcp-Session-Id response header when the transport is in
|
|
7591
|
+
// SDK-stateful mode, which requires a sessionIdGenerator. We use
|
|
7592
|
+
// SDK-stateful mode for init requests (so the client receives the sid)
|
|
7593
|
+
// and SDK-stateless mode for subsequent requests (to avoid the SDK's
|
|
7594
|
+
// per-instance _initialized / sessionId equality checks that would reject
|
|
7595
|
+
// a freshly-constructed transport).
|
|
7596
|
+
const isInit = isInitializationRequestBody(req.body);
|
|
7597
|
+
// Always mint a fresh sid so the embedded iat advances on every request.
|
|
7598
|
+
// This makes OAUTH_STATELESS_SESSION_TTL_SECONDS behave as an inactivity
|
|
7599
|
+
// timeout rather than an absolute-age cap — matching the legacy
|
|
7600
|
+
// setAuthTimeout semantics. Reusing the incoming sid would regress
|
|
7601
|
+
// long-lived sessions that authenticate via sealed-sid replay (typical
|
|
7602
|
+
// REMOTE_AUTHORIZATION flow after init).
|
|
7603
|
+
const freshSid = mintSessionId(material, {
|
|
7604
|
+
header: effective.header,
|
|
7605
|
+
token: effective.token,
|
|
7606
|
+
apiUrl: effective.apiUrl,
|
|
7607
|
+
});
|
|
7608
|
+
if (freshAuthPresent) {
|
|
7609
|
+
metrics.statelessSidRotated++;
|
|
7610
|
+
}
|
|
7611
|
+
logger.debug({
|
|
7612
|
+
sidPrefix: redactSessionIdForLog(freshSid),
|
|
7613
|
+
freshAuthPresent,
|
|
7614
|
+
header: effective.header,
|
|
7615
|
+
}, "stateless /mcp request");
|
|
7616
|
+
// Step 3: build the AsyncLocalStorage context so buildAuthHeaders and
|
|
7617
|
+
// getEffectiveApiUrl read from the derived auth.
|
|
7618
|
+
const ctx = {
|
|
7619
|
+
sessionId: freshSid,
|
|
7620
|
+
header: effective.header,
|
|
7621
|
+
token: effective.token,
|
|
7622
|
+
lastUsed: Date.now(),
|
|
7623
|
+
apiUrl: effective.apiUrl,
|
|
7624
|
+
};
|
|
7625
|
+
// Step 4: create a fresh transport per request.
|
|
7626
|
+
const transport = isInit
|
|
7627
|
+
? new StreamableHTTPServerTransport({
|
|
7628
|
+
sessionIdGenerator: () => freshSid,
|
|
7629
|
+
})
|
|
7630
|
+
: new StreamableHTTPServerTransport({
|
|
7631
|
+
sessionIdGenerator: undefined, // SDK stateless mode for non-init
|
|
7632
|
+
});
|
|
7633
|
+
// For non-init requests the SDK runs in its internal stateless mode and
|
|
7634
|
+
// does not emit an Mcp-Session-Id response header. We pre-set the
|
|
7635
|
+
// freshly minted sid on the Express response so clients can adopt the
|
|
7636
|
+
// latest sid (and its advanced iat) on every response. Headers passed
|
|
7637
|
+
// to the SDK's writeHead() call are merged with pre-set headers per
|
|
7638
|
+
// Node.js semantics, so this does not clobber SDK-managed values.
|
|
7639
|
+
// Without this, the inactivity-timeout semantics of
|
|
7640
|
+
// OAUTH_STATELESS_SESSION_TTL_SECONDS silently regress to an
|
|
7641
|
+
// absolute-age cap for sid-only auth flows.
|
|
7642
|
+
if (!isInit) {
|
|
7643
|
+
res.setHeader("Mcp-Session-Id", freshSid);
|
|
7644
|
+
}
|
|
7645
|
+
const serverInstance = createServer();
|
|
7646
|
+
await serverInstance.connect(transport);
|
|
7647
|
+
await sessionAuthStore.run(ctx, async () => {
|
|
7648
|
+
try {
|
|
7649
|
+
await transport.handleRequest(req, res, req.body);
|
|
7650
|
+
}
|
|
7651
|
+
catch (err) {
|
|
7652
|
+
logger.error({ err }, "stateless /mcp error");
|
|
7653
|
+
if (!res.headersSent) {
|
|
7654
|
+
res.status(500).json({
|
|
7655
|
+
error: "Internal server error",
|
|
7656
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
7657
|
+
});
|
|
7658
|
+
}
|
|
7659
|
+
}
|
|
7660
|
+
finally {
|
|
7661
|
+
// Fresh transport per request — always close to release any stream
|
|
7662
|
+
// resources. The SDK treats close() as idempotent.
|
|
7663
|
+
try {
|
|
7664
|
+
await transport.close();
|
|
7665
|
+
}
|
|
7666
|
+
catch {
|
|
7667
|
+
// ignore
|
|
7668
|
+
}
|
|
7669
|
+
}
|
|
7670
|
+
});
|
|
7671
|
+
};
|
|
7365
7672
|
// Configure Express middleware
|
|
7366
7673
|
app.use(express.json());
|
|
7367
7674
|
// MCP OAuth — mount auth router and prepare bearer-auth middleware
|
|
@@ -7374,7 +7681,15 @@ async function startStreamableHTTPServer() {
|
|
|
7374
7681
|
const callbackUrl = GITLAB_OAUTH_CALLBACK_PROXY
|
|
7375
7682
|
? `${issuerUrl.origin}${issuerUrl.pathname.replace(/\/$/, "")}/callback`
|
|
7376
7683
|
: undefined;
|
|
7377
|
-
const
|
|
7684
|
+
const statelessOptions = OAUTH_STATELESS_MODE && STATELESS_MATERIAL
|
|
7685
|
+
? {
|
|
7686
|
+
material: STATELESS_MATERIAL,
|
|
7687
|
+
clientTtlSeconds: OAUTH_STATELESS_CLIENT_TTL_SECONDS,
|
|
7688
|
+
pendingTtlSeconds: OAUTH_STATELESS_PENDING_TTL_SECONDS,
|
|
7689
|
+
storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
|
|
7690
|
+
}
|
|
7691
|
+
: null;
|
|
7692
|
+
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
|
|
7378
7693
|
const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
|
|
7379
7694
|
// When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
|
|
7380
7695
|
// advertises root-level endpoints. Override to use path-prefixed endpoints.
|
|
@@ -7467,14 +7782,40 @@ async function startStreamableHTTPServer() {
|
|
|
7467
7782
|
});
|
|
7468
7783
|
return;
|
|
7469
7784
|
}
|
|
7785
|
+
// Stateless-mode sid bypass: when the client sends only an
|
|
7786
|
+
// Mcp-Session-Id (no live Authorization), let handleStatelessMcpRequest
|
|
7787
|
+
// open the sealed sid. Without this, requireBearerAuth would 401
|
|
7788
|
+
// before the handler can reconstruct auth from the sid — breaking
|
|
7789
|
+
// sid-only follow-up requests across pods under GITLAB_MCP_OAUTH.
|
|
7790
|
+
//
|
|
7791
|
+
// We still run oauthBearerAuth when an Authorization header IS
|
|
7792
|
+
// present alongside the sid, so a client refreshing its OAuth token
|
|
7793
|
+
// gets the new token validated normally. We also key this off
|
|
7794
|
+
// header *presence* (not validity): malformed / expired / legacy
|
|
7795
|
+
// sids still reach the handler and get the intended 404 Session
|
|
7796
|
+
// not found response rather than being masked by a 401 here.
|
|
7797
|
+
if (hasStatelessSessionId(req) && !req.headers.authorization) {
|
|
7798
|
+
next();
|
|
7799
|
+
return;
|
|
7800
|
+
}
|
|
7470
7801
|
oauthBearerAuth(req, res, next);
|
|
7471
7802
|
}
|
|
7472
7803
|
: (_req, _res, next) => next();
|
|
7473
7804
|
// Streamable HTTP endpoint - handles both session creation and message handling
|
|
7474
7805
|
app.post("/mcp", mcpBearerAuth, async (req, res) => {
|
|
7475
|
-
const sessionId = req
|
|
7806
|
+
const sessionId = readMcpSessionIdHeader(req);
|
|
7476
7807
|
// Track request
|
|
7477
7808
|
metrics.requestsProcessed++;
|
|
7809
|
+
// Stateless-mode branch: bypass authBySession / streamableTransports
|
|
7810
|
+
// entirely and derive the session auth from either the current request
|
|
7811
|
+
// headers (init) or a sealed Mcp-Session-Id (subsequent requests).
|
|
7812
|
+
// Rate limiting is disabled here because there is no shared counter.
|
|
7813
|
+
if (OAUTH_STATELESS_MODE &&
|
|
7814
|
+
STATELESS_MATERIAL &&
|
|
7815
|
+
(REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
|
|
7816
|
+
await handleStatelessMcpRequest(req, res, STATELESS_MATERIAL, OAUTH_STATELESS_SESSION_TTL_SECONDS);
|
|
7817
|
+
return;
|
|
7818
|
+
}
|
|
7478
7819
|
// Rate limiting check for existing sessions
|
|
7479
7820
|
if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && !checkRateLimit(sessionId)) {
|
|
7480
7821
|
metrics.rejectedByRateLimit++;
|
|
@@ -7697,6 +8038,8 @@ async function startStreamableHTTPServer() {
|
|
|
7697
8038
|
sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
|
|
7698
8039
|
remoteAuthEnabled: REMOTE_AUTHORIZATION,
|
|
7699
8040
|
mcpOAuthEnabled: GITLAB_MCP_OAUTH,
|
|
8041
|
+
statelessModeEnabled: OAUTH_STATELESS_MODE && STATELESS_MATERIAL !== null,
|
|
8042
|
+
statelessRotationKey: OAUTH_STATELESS_MODE && STATELESS_MATERIAL?.previous != null,
|
|
7700
8043
|
},
|
|
7701
8044
|
});
|
|
7702
8045
|
});
|
|
@@ -7711,8 +8054,8 @@ async function startStreamableHTTPServer() {
|
|
|
7711
8054
|
});
|
|
7712
8055
|
});
|
|
7713
8056
|
// to delete a mcp server session explicitly
|
|
7714
|
-
app.delete("/mcp", async (req, res) => {
|
|
7715
|
-
const sessionId = req
|
|
8057
|
+
app.delete("/mcp", mcpBearerAuth, async (req, res) => {
|
|
8058
|
+
const sessionId = readMcpSessionIdHeader(req);
|
|
7716
8059
|
if (!sessionId) {
|
|
7717
8060
|
res.status(400).json({ error: "mcp-session-id header is required" });
|
|
7718
8061
|
return;
|