Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ the token to GitLab on behalf of the caller.
| `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
| `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed |
| `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, and `tools/list` only (tool calls still require auth) |
| `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | optional | Allowed public `/mcp` host/origin values for DNS rebinding protection |
| `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers behind a reverse proxy (download URLs, Express `req.ip`, OAuth rate limits) |

`GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY=true` is intended for MCP gateways
Expand Down Expand Up @@ -323,6 +324,8 @@ Commonly referenced variables:
- `GITLAB_USE_OAUTH`
- `REMOTE_AUTHORIZATION`
- `MCP_TRUST_PROXY`
- `MCP_ALLOWED_HOSTS`
- `MCP_ALLOWED_ORIGINS`
- `GITLAB_MCP_OAUTH`
- `GITLAB_OAUTH_CALLBACK_PROXY`
- `OAUTH_STATELESS_MODE`
Expand Down Expand Up @@ -475,7 +478,7 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
| ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITLAB_MCP_OAUTH` | Yes | Set to `true` to enable |
| `GITLAB_OAUTH_APP_ID` | Yes | Client ID of the pre-registered GitLab OAuth application |
| `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server |
| `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server; also allowed for `/mcp` Host/Origin checks |
| `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`) |
| `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) |
| `GITLAB_OAUTH_SCOPES` | No | Comma-separated GitLab scopes to request (e.g. `api,read_user`). Defaults to `api` (or `read_api` when `GITLAB_READ_ONLY_MODE=true`). The pre-registered application must be configured with at least these scopes. |
Expand Down
16 changes: 16 additions & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,22 @@ Legacy additive flag for pipeline-related tools.

Set to `true` to run the Streamable HTTP transport.

Streamable HTTP allows `/mcp` requests when the `Host` header is local
(`127.0.0.1`, `localhost`, `[::1]`), matches `MCP_SERVER_URL`, or is listed in
`MCP_ALLOWED_HOSTS`. When an `Origin` header is present, it must be a local
origin, match `MCP_SERVER_URL`, or be listed in `MCP_ALLOWED_ORIGINS`.

### `MCP_ALLOWED_HOSTS`

Comma-separated extra allowed `Host` header values for `/mcp`.
Use this when the public host clients send differs from `MCP_SERVER_URL`.
Values may be bare hosts (`mcp.example.com`) or host:port pairs.

### `MCP_ALLOWED_ORIGINS`

Comma-separated extra allowed browser origins for `/mcp`, for example
`https://mcp.example.com`.

### `SSE`

Set to `true` to run the legacy SSE transport.
Expand Down
112 changes: 112 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,100 @@ function isLoopbackBindHost(host: string): boolean {
);
}

function formatHostWithPort(host: string, port: number): string | null {
const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
if (!normalized || normalized === "0.0.0.0" || normalized === "::") return null;
if (normalized.includes(":")) return `[${normalized}]:${port}`;
return `${normalized}:${port}`;
}

function toAllowedMcpHost(value: string): string | null {
const trimmed = value.trim().toLowerCase();
if (!trimmed || trimmed.includes(" ")) return null;

try {
if (trimmed.includes("://")) return new URL(trimmed).host.toLowerCase();
if (trimmed.includes("/")) return null;
return new URL(`http://${trimmed}`).host.toLowerCase();
} catch {
try {
return new URL(`http://[${trimmed}]`).host.toLowerCase();
} catch {
return null;
}
}
}

function toAllowedMcpOrigin(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;

try {
const origin = new URL(trimmed).origin;
return origin === "null" ? null : origin.toLowerCase();
} catch {
return null;
}
}

function getMcpDnsRebindingProtection() {
const allowedHosts = new Set<string>();
const allowedOrigins = new Set<string>();
const addHost = (value: string | null | undefined) => {
const host = value ? toAllowedMcpHost(value) : null;
if (host) allowedHosts.add(host);
};
const addOrigin = (value: string | null | undefined) => {
const origin = value ? toAllowedMcpOrigin(value) : null;
if (origin) allowedOrigins.add(origin);
};

for (const host of ["127.0.0.1", "localhost", "::1", HOST]) {
const withPort = formatHostWithPort(host, PORT);
addHost(withPort);
if (withPort) addOrigin(`http://${withPort}`);
}

if (MCP_SERVER_URL) {
addHost(MCP_SERVER_URL);
addOrigin(MCP_SERVER_URL);
}

for (const host of (getConfig("mcp-allowed-hosts", "MCP_ALLOWED_HOSTS") || "").split(",")) {
addHost(host);
}
for (const origin of (getConfig("mcp-allowed-origins", "MCP_ALLOWED_ORIGINS") || "").split(",")) {
addOrigin(origin);
}

return {
enableDnsRebindingProtection: true,
allowedHosts: [...allowedHosts],
allowedOrigins: [...allowedOrigins],
};
}

const MCP_DNS_REBINDING_PROTECTION = getMcpDnsRebindingProtection();

function requireMcpHostAndOrigin(req: Request, res: Response, next: NextFunction) {
const host = toAllowedMcpHost(req.headers.host || "");
if (!host || !MCP_DNS_REBINDING_PROTECTION.allowedHosts.includes(host)) {
res.status(403).json({ error: "Host header is not allowed" });
return;
}

const originHeader = req.headers.origin;
if (originHeader) {
const origin = toAllowedMcpOrigin(Array.isArray(originHeader) ? originHeader[0] : originHeader);
if (!origin || !MCP_DNS_REBINDING_PROTECTION.allowedOrigins.includes(origin)) {
res.status(403).json({ error: "Origin header is not allowed" });
return;
}
}

next();
}

function validateConfiguration(): void {
const errors: string[] = [];

Expand Down Expand Up @@ -1021,6 +1115,20 @@ function validateConfiguration(): void {
}
}

const mcpAllowedHosts = getConfig("mcp-allowed-hosts", "MCP_ALLOWED_HOSTS")?.split(",") || [];
for (const host of mcpAllowedHosts) {
if (host.trim() && !toAllowedMcpHost(host)) {
errors.push(`MCP_ALLOWED_HOSTS contains an invalid host or URL: ${host.trim()}`);
}
}

const mcpAllowedOrigins = getConfig("mcp-allowed-origins", "MCP_ALLOWED_ORIGINS")?.split(",") || [];
for (const origin of mcpAllowedOrigins) {
if (origin.trim() && !toAllowedMcpOrigin(origin)) {
errors.push(`MCP_ALLOWED_ORIGINS contains an invalid origin URL: ${origin.trim()}`);
}
}

// Validate auth configuration
const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
Expand Down Expand Up @@ -12684,9 +12792,11 @@ async function startStreamableHTTPServer(): Promise<void> {
// Step 4: create a fresh transport per request.
const transport = isInit
? new StreamableHTTPServerTransport({
...MCP_DNS_REBINDING_PROTECTION,
sessionIdGenerator: () => freshSid,
})
: new StreamableHTTPServerTransport({
...MCP_DNS_REBINDING_PROTECTION,
sessionIdGenerator: undefined, // SDK stateless mode for non-init
});

Expand Down Expand Up @@ -12734,6 +12844,7 @@ async function startStreamableHTTPServer(): Promise<void> {
app.set("trust proxy", 1);
}

app.use("/mcp", requireMcpHostAndOrigin);
app.use(express.json());

registerDownloadProxy(app);
Expand Down Expand Up @@ -13051,6 +13162,7 @@ async function startStreamableHTTPServer(): Promise<void> {
} else {
// Create new transport for new session
transport = new StreamableHTTPServerTransport({
...MCP_DNS_REBINDING_PROTECTION,
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId: string) => {
streamableTransports[newSessionId] = transport;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"changelog": "auto-changelog -p",
"test": "npm run test:all",
"test:all": "npm run build && npm run test:mock && npm run test:live",
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/dynamic-api-url-allowlist.test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/sse-auth-guard.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/dynamic-api-url-allowlist.test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-dns-rebinding.test.ts && node --import tsx/esm --test test/sse-auth-guard.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
"test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
"test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
"test:live": "node test/validate-api.js",
Expand Down
Loading
Loading