Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ ISSUE_IID=1
# 3. **Stdio** (if `SSE!=true` and `STREAMABLE_HTTP!=true`)
SSE=true
STREAMABLE_HTTP=false
# Maximum encoded JSON request size for HTTP transports. Default: 10mb
# GITLAB_MCP_REQUEST_BODY_LIMIT=10mb

# MCP Server Host With SSE Transport and Streamable Http Transport
HOST=127.0.0.1
Expand Down
2 changes: 2 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export const SSE = getConfig("sse", "SSE") === "true";
export const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
export const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
export const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
export const GITLAB_MCP_REQUEST_BODY_LIMIT =
process.env.GITLAB_MCP_REQUEST_BODY_LIMIT?.trim() || "10mb";
export const GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY =
getConfig(
"allow-unauthenticated-tool-discovery",
Expand Down
18 changes: 18 additions & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,24 @@ LOG_FORMAT=json LOG_LEVEL=warn npm start

## Transport and Server Runtime

### `GITLAB_MCP_REQUEST_BODY_LIMIT`

Maximum encoded JSON request size accepted by Streamable HTTP and SSE transports.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
The limit applies to the complete JSON-RPC request, including its envelope and all
arguments, rather than only to note text. Values use the syntax accepted by
Express, such as `512kb`, `10mb`, or a byte count.

Default:

- `10mb`

The default accommodates GitLab's 1,000,000-character note limit even when JSON
escaping or UTF-8 encoding makes the request several times larger than its
character count. Lower the value only when clients do not need large note, file,
or CI configuration payloads. Reverse proxies can impose a separate request-size
limit and must be configured independently. The larger parser is mounted only on
MCP message routes, after their transport-level request controls.

### `STREAMABLE_HTTP`

Set to `true` to run the Streamable HTTP transport.
Expand Down
37 changes: 28 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
GITLAB_CA_CERT_PATH,
GITLAB_JOB_TOKEN,
GITLAB_MCP_OAUTH,
GITLAB_MCP_REQUEST_BODY_LIMIT,
GITLAB_OAUTH_APP_ID,
GITLAB_OAUTH_SCOPES,
GITLAB_OAUTH_CALLBACK_PROXY,
Expand Down Expand Up @@ -862,7 +863,7 @@
})),
}));

mcpServer.server.setRequestHandler(GetPromptRequestSchema, async (request: any) => {

Check warning on line 866 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
const prompt = MCP_PROMPTS.find(p => p.name === request.params.name);
if (!prompt) {
throw new Error(`Unknown prompt: ${request.params.name}`);
Expand All @@ -888,7 +889,7 @@
};
});

mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request: any) => {

Check warning on line 892 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
// Manually retrieve the session context using the session ID passed in the request.
// This is a robust workaround for AsyncLocalStorage context loss.
const sessionId = request.params.sessionId;
Expand All @@ -899,7 +900,7 @@
request.params.arguments as Record<string, unknown> | undefined
);

const logCompletion = (result: any, completionOptions?: { skipJmespath?: boolean }) => {

Check warning on line 903 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
const durationMs = Date.now() - start;
logger.info(
{ tool: toolName, event: "tool_call_done", durationMs },
Expand All @@ -911,7 +912,7 @@
(toolName === "download_release_asset" && !IS_REMOTE);
const filtered = applyJmespathToToolResult(result, jmespathExpression, {
...completionOptions,
maskValue: maskingEngine ? (value: unknown) => maskingEngine!.maskValue(value) : undefined,

Check warning on line 915 in index.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
});
const masked = maskingEngine
? maskingEngine.maskToolResult(filtered, {
Expand Down Expand Up @@ -1792,7 +1793,7 @@
function defaultAuthRetryConfig() {
return {
isOAuthEnabled: () => USE_OAUTH && oauthClient != null,
refreshToken: (force: boolean) => oauthClient!.getAccessToken(force),

Check warning on line 1796 in index.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
onTokenRefreshed: (token: string) => {
OAUTH_ACCESS_TOKEN = token;
},
Expand Down Expand Up @@ -2660,7 +2661,7 @@
);

// Build request body, converting labels array to comma-separated string
const body: Record<string, any> = { ...options };

Check warning on line 2664 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
if (body.labels && Array.isArray(body.labels)) {
body.labels = body.labels.join(",");
}
Expand Down Expand Up @@ -2908,7 +2909,7 @@
);

// Convert labels array to comma-separated string if present
const body: Record<string, any> = { ...options };

Check warning on line 2912 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
if (body.labels && Array.isArray(body.labels)) {
body.labels = body.labels.join(",");
}
Expand Down Expand Up @@ -15948,6 +15949,7 @@
async function startSSEServer(): Promise<void> {
const { express, rateLimit, ipKeyGenerator, SSEServerTransport } = await loadRemoteHttpStack();
const app = express();
const sseJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });
const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");

if (MCP_TRUST_PROXY) {
Expand Down Expand Up @@ -16130,16 +16132,32 @@
}
);

app.post("/messages", rejectDnsRebinding, requireSseAuth, async (req: Request, res: Response) => {
const sessionId = req.query.sessionId as string;
const transport = transports[sessionId];
if (transport) {
sessionLastActivity.set(sessionId, Date.now());
await transport.handlePostMessage(req, res);
} else {
const requireSseSession = (req: Request, res: Response, next: NextFunction) => {
const sessionId = req.query.sessionId;
if (typeof sessionId !== "string" || !transports[sessionId]) {
res.status(400).send("No transport found for sessionId");
return;
}
});
next();
};

app.post(
"/messages",
rejectDnsRebinding,
requireSseAuth,
requireSseSession,
sseJsonBodyParser,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
async (req: Request, res: Response) => {
const sessionId = req.query.sessionId as string;
const transport = transports[sessionId];
if (transport) {
sessionLastActivity.set(sessionId, Date.now());
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send("No transport found for sessionId");
}
}
);

registerDownloadProxy(app, buildDownloadProxyDeps());

Expand Down Expand Up @@ -16803,7 +16821,7 @@
}

app.use("/mcp", requireMcpHostAndOrigin);
app.use(express.json());
const mcpJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT });

registerDownloadProxy(app, buildDownloadProxyDeps());

Expand Down Expand Up @@ -17094,6 +17112,7 @@
mcpRequestRateLimit,
mcpBearerAuth,
rejectForeignSession,
mcpJsonBodyParser,
async (req: Request, res: Response) => {
const sessionId = readMcpSessionIdHeader(req);
const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY);
Expand Down
23 changes: 23 additions & 0 deletions test/note-body-length-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { CreateDraftNoteSchema, CreateMergeRequestNoteSchema } from "../schemas.js";

const GITLAB_NOTE_CHARACTER_LIMIT = 1_000_000;

test("merge request note input schemas accept GitLab's note character limit", () => {
const body = "a".repeat(GITLAB_NOTE_CHARACTER_LIMIT);

const note = CreateMergeRequestNoteSchema.parse({
project_id: "group/project",
merge_request_iid: 1,
body,
});
const draft = CreateDraftNoteSchema.parse({
project_id: "group/project",
merge_request_iid: 1,
body,
});

assert.equal(note.body.length, GITLAB_NOTE_CHARACTER_LIMIT);
assert.equal(draft.body.length, GITLAB_NOTE_CHARACTER_LIMIT);
});
60 changes: 60 additions & 0 deletions test/sse-session-limits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ async function postSseMessage(port: number, sessionId: string, body: unknown): P
return response.status;
}

async function postRawSseMessage(port: number, sessionId: string, body: string): Promise<number> {
const response = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=${sessionId}`, {
method: "POST",
headers: { Authorization: `Bearer ${SSE_TOKEN}`, "Content-Type": "application/json" },
body,
});
await response.arrayBuffer();
return response.status;
}

function sseMessageWithEncodedLength(encodedLength: number): string {
const message = JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" });
assert.ok(encodedLength >= Buffer.byteLength(message));
return message + " ".repeat(encodedLength - Buffer.byteLength(message));
}

async function getSseStatus(port: number): Promise<number> {
const response = await fetch(`http://${LOOPBACK}:${port}/sse`, {
headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
Expand Down Expand Up @@ -178,6 +194,50 @@ afterEach(() => {
});

describe("SSE session limits", { concurrency: 1 }, () => {
test("accepts messages above the SDK's fallback 4 MiB limit", async () => {
const port = await findAvailablePort(4490);
startSseServer({ MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port);
await waitForHealth(port);

const connection = openSseConnection(port);
const sessionId = await readSessionId(connection);

assert.equal(
await postRawSseMessage(port, sessionId, sseMessageWithEncodedLength(5 * 1024 * 1024)),
202
);
connection.close();
});

test("honors the configured request body limit for messages", async () => {
const port = await findAvailablePort(4495);
startSseServer(
{
GITLAB_MCP_REQUEST_BODY_LIMIT: "20kb",
MAX_REQUESTS_PER_MINUTE: UNTHROTTLED,
},
port
);
await waitForHealth(port);

const connection = openSseConnection(port);
const sessionId = await readSessionId(connection);

assert.equal(
await postRawSseMessage(port, "missing", sseMessageWithEncodedLength(20 * 1024 + 1)),
400
);
assert.equal(
await postRawSseMessage(port, sessionId, sseMessageWithEncodedLength(20 * 1024)),
202
);
assert.equal(
await postRawSseMessage(port, sessionId, sseMessageWithEncodedLength(20 * 1024 + 1)),
413
);
connection.close();
});

test("rejects new SSE connections once MAX_SESSIONS is reached", async () => {
const port = await findAvailablePort(4500);
startSseServer({ MAX_SESSIONS: "2", MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port);
Expand Down
110 changes: 110 additions & 0 deletions test/streamable-http-body-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { afterEach, describe, test } from "node:test";
import {
cleanupServers,
findAvailablePort,
launchServer,
ServerInstance,
TransportMode,
} from "./utils/server-launcher.js";

const HOST = process.env.HOST || "127.0.0.1";
const GITLAB_NOTE_CHARACTER_LIMIT = 1_000_000;
const TWENTY_KIB = 20 * 1024;

const servers: ServerInstance[] = [];

function noteRequest(bodyLength: number): string {
return JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "create_merge_request_note",
arguments: {
project_id: "group/project",
merge_request_iid: 1,
body: "a".repeat(bodyLength),
},
},
});
}

function noteRequestWithEncodedLength(encodedLength: number): string {
const emptyRequest = noteRequest(0);
const fixedLength = Buffer.byteLength(emptyRequest);

assert.ok(encodedLength >= fixedLength);
const request = noteRequest(encodedLength - fixedLength);
assert.equal(Buffer.byteLength(request), encodedLength);
return request;
}

async function startServer(basePort: number, env: Record<string, string> = {}): Promise<string> {
const port = await findAvailablePort(basePort);
const server = await launchServer({
mode: TransportMode.STREAMABLE_HTTP,
port,
env: {
GITLAB_API_URL: "https://gitlab.example.com/api/v4",
REMOTE_AUTHORIZATION: "true",
...env,
},
});
servers.push(server);
return `http://${HOST}:${port}/mcp`;
}

async function postNoteRequest(mcpUrl: string, body: string): Promise<Response> {
return fetch(mcpUrl, {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
body,
});
}

afterEach(() => {
cleanupServers(servers);
servers.length = 0;
});

describe("Streamable HTTP request body limit", () => {
test("parses a note at GitLab's character limit by default", async () => {
const mcpUrl = await startServer(4590);
const response = await postNoteRequest(mcpUrl, noteRequest(GITLAB_NOTE_CHARACTER_LIMIT));

assert.equal(response.status, 401);
});

test("honors a configured request body limit", async () => {
const mcpUrl = await startServer(4600, { GITLAB_MCP_REQUEST_BODY_LIMIT: "20kb" });

const accepted = await postNoteRequest(mcpUrl, noteRequestWithEncodedLength(TWENTY_KIB));
assert.equal(accepted.status, 401);

const rejected = await postNoteRequest(mcpUrl, noteRequestWithEncodedLength(TWENTY_KIB + 1));
assert.equal(rejected.status, 413);

const unrelatedRoute = await postNoteRequest(
mcpUrl.replace(/\/mcp$/, "/health"),
noteRequestWithEncodedLength(TWENTY_KIB + 1)
);
assert.equal(unrelatedRoute.status, 404);
});

test("rate-limits an oversized request before parsing it", async () => {
const mcpUrl = await startServer(4610, {
GITLAB_MCP_REQUEST_BODY_LIMIT: "20kb",
MAX_REQUESTS_PER_MINUTE: "1",
});

const first = await postNoteRequest(mcpUrl, noteRequest(1));
assert.equal(first.status, 401);

const rateLimited = await postNoteRequest(mcpUrl, noteRequestWithEncodedLength(TWENTY_KIB + 1));
assert.equal(rateLimited.status, 429);
});
});
Loading