@zereight/mcp-gitlab 2.1.5 → 2.1.7

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/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";
@@ -32,11 +33,37 @@ GitLabDiscussionNoteSchema, // Added
32
33
  GitLabDiscussionSchema,
33
34
  // Draft Notes Schemas
34
35
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
35
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
36
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
36
37
  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).
@@ -5582,6 +5700,92 @@ async function downloadReleaseAsset(projectId, tagName, directAssetPath) {
5582
5700
  await handleGitLabError(response);
5583
5701
  return await response.text();
5584
5702
  }
5703
+ /**
5704
+ * List repository tags
5705
+ *
5706
+ * @param projectId The ID or URL-encoded path of the project
5707
+ * @param options Optional parameters for filtering and pagination
5708
+ * @returns Array of GitLab tags
5709
+ */
5710
+ async function listTags(projectId, options = {}) {
5711
+ const effectiveProjectId = getEffectiveProjectId(projectId);
5712
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/tags`);
5713
+ Object.entries(options).forEach(([key, value]) => {
5714
+ if (value !== undefined) {
5715
+ url.searchParams.append(key, value.toString());
5716
+ }
5717
+ });
5718
+ const response = await fetch(url.toString(), {
5719
+ ...getFetchConfig(),
5720
+ });
5721
+ await handleGitLabError(response);
5722
+ const data = await response.json();
5723
+ return GitLabTagSchema.array().parse(data);
5724
+ }
5725
+ /**
5726
+ * Get a repository tag by name
5727
+ *
5728
+ * @param projectId The ID or URL-encoded path of the project
5729
+ * @param tagName The name of the tag
5730
+ * @returns GitLab tag
5731
+ */
5732
+ async function getTag(projectId, tagName) {
5733
+ const effectiveProjectId = getEffectiveProjectId(projectId);
5734
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/tags/${encodeURIComponent(tagName)}`, {
5735
+ ...getFetchConfig(),
5736
+ });
5737
+ await handleGitLabError(response);
5738
+ const data = await response.json();
5739
+ return GitLabTagSchema.parse(data);
5740
+ }
5741
+ /**
5742
+ * Create a new repository tag
5743
+ *
5744
+ * @param projectId The ID or URL-encoded path of the project
5745
+ * @param options Options for creating the tag
5746
+ * @returns Created GitLab tag
5747
+ */
5748
+ async function createTag(projectId, options) {
5749
+ const effectiveProjectId = getEffectiveProjectId(projectId);
5750
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/tags`, {
5751
+ ...getFetchConfig(),
5752
+ method: "POST",
5753
+ body: JSON.stringify(options),
5754
+ });
5755
+ await handleGitLabError(response);
5756
+ const data = await response.json();
5757
+ return GitLabTagSchema.parse(data);
5758
+ }
5759
+ /**
5760
+ * Delete a repository tag
5761
+ *
5762
+ * @param projectId The ID or URL-encoded path of the project
5763
+ * @param tagName The name of the tag
5764
+ */
5765
+ async function deleteTag(projectId, tagName) {
5766
+ const effectiveProjectId = getEffectiveProjectId(projectId);
5767
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/tags/${encodeURIComponent(tagName)}`, {
5768
+ ...getFetchConfig(),
5769
+ method: "DELETE",
5770
+ });
5771
+ await handleGitLabError(response);
5772
+ }
5773
+ /**
5774
+ * Get the signature of a repository tag
5775
+ *
5776
+ * @param projectId The ID or URL-encoded path of the project
5777
+ * @param tagName The name of the tag
5778
+ * @returns Tag signature
5779
+ */
5780
+ async function getTagSignature(projectId, tagName) {
5781
+ const effectiveProjectId = getEffectiveProjectId(projectId);
5782
+ const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/repository/tags/${encodeURIComponent(tagName)}/signature`, {
5783
+ ...getFetchConfig(),
5784
+ });
5785
+ await handleGitLabError(response);
5786
+ const data = await response.json();
5787
+ return GitLabTagSignatureSchema.parse(data);
5788
+ }
5585
5789
  // Request handlers are now registered inside createServer() factory function
5586
5790
  // to ensure each transport connection gets its own Server instance (GHSA-345p-7cg4-v4c7).
5587
5791
  async function handleToolCall(params) {
@@ -7073,6 +7277,48 @@ async function handleToolCall(params) {
7073
7277
  content: [{ type: "text", text: assetContent }],
7074
7278
  };
7075
7279
  }
7280
+ case "list_tags": {
7281
+ const args = ListTagsSchema.parse(params.arguments);
7282
+ const { project_id, ...options } = args;
7283
+ const tags = await listTags(project_id, options);
7284
+ return {
7285
+ content: [{ type: "text", text: JSON.stringify(tags, null, 2) }],
7286
+ };
7287
+ }
7288
+ case "get_tag": {
7289
+ const args = GetTagSchema.parse(params.arguments);
7290
+ const tag = await getTag(args.project_id, args.tag_name);
7291
+ return {
7292
+ content: [{ type: "text", text: JSON.stringify(tag, null, 2) }],
7293
+ };
7294
+ }
7295
+ case "create_tag": {
7296
+ const args = CreateTagSchema.parse(params.arguments);
7297
+ const { project_id, ...options } = args;
7298
+ const tag = await createTag(project_id, options);
7299
+ return {
7300
+ content: [{ type: "text", text: JSON.stringify(tag, null, 2) }],
7301
+ };
7302
+ }
7303
+ case "delete_tag": {
7304
+ const args = DeleteTagSchema.parse(params.arguments);
7305
+ await deleteTag(args.project_id, args.tag_name);
7306
+ return {
7307
+ content: [
7308
+ {
7309
+ type: "text",
7310
+ text: JSON.stringify({ status: "success", message: `Tag '${args.tag_name}' deleted successfully` }, null, 2),
7311
+ },
7312
+ ],
7313
+ };
7314
+ }
7315
+ case "get_tag_signature": {
7316
+ const args = GetTagSignatureSchema.parse(params.arguments);
7317
+ const signature = await getTagSignature(args.project_id, args.tag_name);
7318
+ return {
7319
+ content: [{ type: "text", text: JSON.stringify(signature, null, 2) }],
7320
+ };
7321
+ }
7076
7322
  case "list_webhooks": {
7077
7323
  const args = ListWebhooksSchema.parse(params.arguments);
7078
7324
  const webhooks = await listWebhooks(args);
@@ -7238,6 +7484,12 @@ async function startStreamableHTTPServer() {
7238
7484
  requestsProcessed: 0,
7239
7485
  rejectedByRateLimit: 0,
7240
7486
  rejectedByCapacity: 0,
7487
+ // Stateless-mode counters. Only non-zero when OAUTH_STATELESS_MODE=true.
7488
+ statelessRequests: 0,
7489
+ statelessAuthFromHeader: 0, // fresh Authorization/Private-Token/JOB-TOKEN present
7490
+ statelessAuthFromSealedSid: 0, // auth reconstructed from sealed Mcp-Session-Id
7491
+ statelessAuthFailures: 0, // neither source yielded usable auth
7492
+ statelessSidRotated: 0, // minted a new sid because fresh auth was present
7241
7493
  };
7242
7494
  // Rate limiting per session
7243
7495
  const sessionRequestCounts = {};
@@ -7362,6 +7614,189 @@ async function startStreamableHTTPServer() {
7362
7614
  delete authBySession[sessionId];
7363
7615
  clearAuthTimeout(sessionId);
7364
7616
  };
7617
+ /**
7618
+ * Stateless-mode handler for /mcp POSTs.
7619
+ *
7620
+ * The MCP Streamable HTTP SDK can run in "stateless mode" when
7621
+ * `sessionIdGenerator` is undefined — it creates no session state and
7622
+ * short-circuits session validation. We exploit that by driving the
7623
+ * session identity ourselves via an AEAD-sealed `Mcp-Session-Id`:
7624
+ *
7625
+ * 1. Every request gets a freshly-instantiated transport (SDK-stateless).
7626
+ * 2. The caller's auth is derived from:
7627
+ * - live request headers (preferred; handles OAuth token refresh),
7628
+ * - or the sealed Mcp-Session-Id (for requests that omit headers).
7629
+ * 3. We assign `transport.sessionId = <sealed>` so the SDK emits the
7630
+ * Mcp-Session-Id response header for the client to echo back.
7631
+ * 4. The AsyncLocalStorage auth context is populated from the derived
7632
+ * auth, bypassing authBySession / authTimeouts entirely.
7633
+ *
7634
+ * Rate limiting is explicitly disabled in stateless mode (per-pod counters
7635
+ * would yield a loose global bound — operators can rate-limit upstream).
7636
+ */
7637
+ const handleStatelessMcpRequest = async (req, res, material, sessionTtlSeconds) => {
7638
+ metrics.statelessRequests++;
7639
+ // Step 1: derive the effective auth for this request.
7640
+ // Priority: live headers > sealed sid. This lets clients refresh OAuth
7641
+ // tokens without re-initializing their MCP session.
7642
+ const headerAuth = parseAuthHeaders(req);
7643
+ // In GITLAB_MCP_OAUTH mode, req.auth may be populated by requireBearerAuth.
7644
+ // Use it when headerAuth is null (no Private-Token / JOB-TOKEN / Authorization).
7645
+ let effective = null;
7646
+ let freshAuthPresent = false;
7647
+ if (headerAuth) {
7648
+ effective = {
7649
+ header: headerAuth.header,
7650
+ token: headerAuth.token,
7651
+ apiUrl: headerAuth.apiUrl,
7652
+ };
7653
+ freshAuthPresent = true;
7654
+ }
7655
+ else if (GITLAB_MCP_OAUTH) {
7656
+ const authInfo = req.auth;
7657
+ if (authInfo?.token) {
7658
+ effective = {
7659
+ header: "Authorization",
7660
+ token: authInfo.token,
7661
+ apiUrl: GITLAB_API_URL,
7662
+ };
7663
+ freshAuthPresent = true;
7664
+ }
7665
+ }
7666
+ if (freshAuthPresent)
7667
+ metrics.statelessAuthFromHeader++;
7668
+ // Fall back to the sealed sid when no live headers are present. Track
7669
+ // whether a sid was presented but rejected (expired / tampered / wrong
7670
+ // key / malformed) so we can return 404 below — the MCP Streamable HTTP
7671
+ // contract is that session-bound requests get 404 on a terminated session,
7672
+ // which tells the client to re-initialize. Returning 401 here would
7673
+ // instead trigger the client's auth-failure path and break automatic
7674
+ // recovery after inactivity TTL expiry.
7675
+ const incomingSid = readMcpSessionIdHeader(req);
7676
+ let sidPresentedButInvalid = false;
7677
+ if (!effective && incomingSid) {
7678
+ if (looksLikeStatelessSessionId(incomingSid)) {
7679
+ const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
7680
+ if (opened) {
7681
+ effective = { header: opened.h, token: opened.t, apiUrl: opened.u };
7682
+ metrics.statelessAuthFromSealedSid++;
7683
+ }
7684
+ else {
7685
+ sidPresentedButInvalid = true;
7686
+ }
7687
+ }
7688
+ else {
7689
+ // Non-stateless-shaped sid (e.g. a UUID from a prior stateful run, or
7690
+ // garbage). From the caller's perspective the session is unknown — we
7691
+ // surface that as "session ended" rather than as an auth problem.
7692
+ sidPresentedButInvalid = true;
7693
+ }
7694
+ }
7695
+ if (!effective) {
7696
+ metrics.authFailures++;
7697
+ metrics.statelessAuthFailures++;
7698
+ if (sidPresentedButInvalid) {
7699
+ // Per MCP Streamable HTTP: a 404 on a session-bound request signals
7700
+ // "session ended, re-initialize". The inactivity TTL expiring looks
7701
+ // identical to a session the server no longer recognizes — in both
7702
+ // cases the client should start a fresh initialize handshake.
7703
+ res.status(404).json({
7704
+ error: "Session not found",
7705
+ message: "Stateless mode: Mcp-Session-Id is expired, invalid, or from a " +
7706
+ "different key. Re-initialize to obtain a new session id.",
7707
+ });
7708
+ return;
7709
+ }
7710
+ res.status(401).json({
7711
+ error: "Authentication required",
7712
+ message: "Stateless mode: provide Private-Token, JOB-TOKEN, or Authorization " +
7713
+ "header, or a valid Mcp-Session-Id from a previous response.",
7714
+ });
7715
+ return;
7716
+ }
7717
+ // Step 2: detect whether this is the initialization request. The MCP SDK
7718
+ // only emits an Mcp-Session-Id response header when the transport is in
7719
+ // SDK-stateful mode, which requires a sessionIdGenerator. We use
7720
+ // SDK-stateful mode for init requests (so the client receives the sid)
7721
+ // and SDK-stateless mode for subsequent requests (to avoid the SDK's
7722
+ // per-instance _initialized / sessionId equality checks that would reject
7723
+ // a freshly-constructed transport).
7724
+ const isInit = isInitializationRequestBody(req.body);
7725
+ // Always mint a fresh sid so the embedded iat advances on every request.
7726
+ // This makes OAUTH_STATELESS_SESSION_TTL_SECONDS behave as an inactivity
7727
+ // timeout rather than an absolute-age cap — matching the legacy
7728
+ // setAuthTimeout semantics. Reusing the incoming sid would regress
7729
+ // long-lived sessions that authenticate via sealed-sid replay (typical
7730
+ // REMOTE_AUTHORIZATION flow after init).
7731
+ const freshSid = mintSessionId(material, {
7732
+ header: effective.header,
7733
+ token: effective.token,
7734
+ apiUrl: effective.apiUrl,
7735
+ });
7736
+ if (freshAuthPresent) {
7737
+ metrics.statelessSidRotated++;
7738
+ }
7739
+ logger.debug({
7740
+ sidPrefix: redactSessionIdForLog(freshSid),
7741
+ freshAuthPresent,
7742
+ header: effective.header,
7743
+ }, "stateless /mcp request");
7744
+ // Step 3: build the AsyncLocalStorage context so buildAuthHeaders and
7745
+ // getEffectiveApiUrl read from the derived auth.
7746
+ const ctx = {
7747
+ sessionId: freshSid,
7748
+ header: effective.header,
7749
+ token: effective.token,
7750
+ lastUsed: Date.now(),
7751
+ apiUrl: effective.apiUrl,
7752
+ };
7753
+ // Step 4: create a fresh transport per request.
7754
+ const transport = isInit
7755
+ ? new StreamableHTTPServerTransport({
7756
+ sessionIdGenerator: () => freshSid,
7757
+ })
7758
+ : new StreamableHTTPServerTransport({
7759
+ sessionIdGenerator: undefined, // SDK stateless mode for non-init
7760
+ });
7761
+ // For non-init requests the SDK runs in its internal stateless mode and
7762
+ // does not emit an Mcp-Session-Id response header. We pre-set the
7763
+ // freshly minted sid on the Express response so clients can adopt the
7764
+ // latest sid (and its advanced iat) on every response. Headers passed
7765
+ // to the SDK's writeHead() call are merged with pre-set headers per
7766
+ // Node.js semantics, so this does not clobber SDK-managed values.
7767
+ // Without this, the inactivity-timeout semantics of
7768
+ // OAUTH_STATELESS_SESSION_TTL_SECONDS silently regress to an
7769
+ // absolute-age cap for sid-only auth flows.
7770
+ if (!isInit) {
7771
+ res.setHeader("Mcp-Session-Id", freshSid);
7772
+ }
7773
+ const serverInstance = createServer();
7774
+ await serverInstance.connect(transport);
7775
+ await sessionAuthStore.run(ctx, async () => {
7776
+ try {
7777
+ await transport.handleRequest(req, res, req.body);
7778
+ }
7779
+ catch (err) {
7780
+ logger.error({ err }, "stateless /mcp error");
7781
+ if (!res.headersSent) {
7782
+ res.status(500).json({
7783
+ error: "Internal server error",
7784
+ message: err instanceof Error ? err.message : "Unknown error",
7785
+ });
7786
+ }
7787
+ }
7788
+ finally {
7789
+ // Fresh transport per request — always close to release any stream
7790
+ // resources. The SDK treats close() as idempotent.
7791
+ try {
7792
+ await transport.close();
7793
+ }
7794
+ catch {
7795
+ // ignore
7796
+ }
7797
+ }
7798
+ });
7799
+ };
7365
7800
  // Configure Express middleware
7366
7801
  app.use(express.json());
7367
7802
  // MCP OAuth — mount auth router and prepare bearer-auth middleware
@@ -7374,7 +7809,15 @@ async function startStreamableHTTPServer() {
7374
7809
  const callbackUrl = GITLAB_OAUTH_CALLBACK_PROXY
7375
7810
  ? `${issuerUrl.origin}${issuerUrl.pathname.replace(/\/$/, "")}/callback`
7376
7811
  : 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);
7812
+ const statelessOptions = OAUTH_STATELESS_MODE && STATELESS_MATERIAL
7813
+ ? {
7814
+ material: STATELESS_MATERIAL,
7815
+ clientTtlSeconds: OAUTH_STATELESS_CLIENT_TTL_SECONDS,
7816
+ pendingTtlSeconds: OAUTH_STATELESS_PENDING_TTL_SECONDS,
7817
+ storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
7818
+ }
7819
+ : null;
7820
+ 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
7821
  const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
7379
7822
  // When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
7380
7823
  // advertises root-level endpoints. Override to use path-prefixed endpoints.
@@ -7467,14 +7910,40 @@ async function startStreamableHTTPServer() {
7467
7910
  });
7468
7911
  return;
7469
7912
  }
7913
+ // Stateless-mode sid bypass: when the client sends only an
7914
+ // Mcp-Session-Id (no live Authorization), let handleStatelessMcpRequest
7915
+ // open the sealed sid. Without this, requireBearerAuth would 401
7916
+ // before the handler can reconstruct auth from the sid — breaking
7917
+ // sid-only follow-up requests across pods under GITLAB_MCP_OAUTH.
7918
+ //
7919
+ // We still run oauthBearerAuth when an Authorization header IS
7920
+ // present alongside the sid, so a client refreshing its OAuth token
7921
+ // gets the new token validated normally. We also key this off
7922
+ // header *presence* (not validity): malformed / expired / legacy
7923
+ // sids still reach the handler and get the intended 404 Session
7924
+ // not found response rather than being masked by a 401 here.
7925
+ if (hasStatelessSessionId(req) && !req.headers.authorization) {
7926
+ next();
7927
+ return;
7928
+ }
7470
7929
  oauthBearerAuth(req, res, next);
7471
7930
  }
7472
7931
  : (_req, _res, next) => next();
7473
7932
  // Streamable HTTP endpoint - handles both session creation and message handling
7474
7933
  app.post("/mcp", mcpBearerAuth, async (req, res) => {
7475
- const sessionId = req.headers["mcp-session-id"];
7934
+ const sessionId = readMcpSessionIdHeader(req);
7476
7935
  // Track request
7477
7936
  metrics.requestsProcessed++;
7937
+ // Stateless-mode branch: bypass authBySession / streamableTransports
7938
+ // entirely and derive the session auth from either the current request
7939
+ // headers (init) or a sealed Mcp-Session-Id (subsequent requests).
7940
+ // Rate limiting is disabled here because there is no shared counter.
7941
+ if (OAUTH_STATELESS_MODE &&
7942
+ STATELESS_MATERIAL &&
7943
+ (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
7944
+ await handleStatelessMcpRequest(req, res, STATELESS_MATERIAL, OAUTH_STATELESS_SESSION_TTL_SECONDS);
7945
+ return;
7946
+ }
7478
7947
  // Rate limiting check for existing sessions
7479
7948
  if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && !checkRateLimit(sessionId)) {
7480
7949
  metrics.rejectedByRateLimit++;
@@ -7697,6 +8166,8 @@ async function startStreamableHTTPServer() {
7697
8166
  sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
7698
8167
  remoteAuthEnabled: REMOTE_AUTHORIZATION,
7699
8168
  mcpOAuthEnabled: GITLAB_MCP_OAUTH,
8169
+ statelessModeEnabled: OAUTH_STATELESS_MODE && STATELESS_MATERIAL !== null,
8170
+ statelessRotationKey: OAUTH_STATELESS_MODE && STATELESS_MATERIAL?.previous != null,
7700
8171
  },
7701
8172
  });
7702
8173
  });
@@ -7711,8 +8182,8 @@ async function startStreamableHTTPServer() {
7711
8182
  });
7712
8183
  });
7713
8184
  // to delete a mcp server session explicitly
7714
- app.delete("/mcp", async (req, res) => {
7715
- const sessionId = req.headers["mcp-session-id"];
8185
+ app.delete("/mcp", mcpBearerAuth, async (req, res) => {
8186
+ const sessionId = readMcpSessionIdHeader(req);
7716
8187
  if (!sessionId) {
7717
8188
  res.status(400).json({ error: "mcp-session-id header is required" });
7718
8189
  return;