#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import * as http from 'http';
import { randomUUID } from 'node:crypto';
import { URL, fileURLToPath } from 'url';
import express from 'express';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { z } from 'zod';
// Helper to break type inference chain and avoid "Type instantiation is excessively deep" errors
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const toJsonSchema = (schema: any): Record<string, unknown> =>
zodToJsonSchema(schema, { target: 'jsonSchema7' }) as Record<string, unknown>;
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { loadConfig } from './config.js';
import { GitLabGraphQLClient } from './gitlab-client.js';
import { tools } from './tools.js';
class GitLabMCPServer {
private server: Server | null = null; // Used only for stdio mode
private gitlabClient!: GitLabGraphQLClient;
private httpSessions: Map<string, {
server: Server;
transport: StreamableHTTPServerTransport;
userConfig?: { accessToken: string; gitlabUrl?: string };
lastActivity: number;
}> = new Map();
private sessionCleanupInterval?: NodeJS.Timeout;
constructor() {
// Initialize GitLab client using environment configuration
const config = loadConfig();
this.gitlabClient = new GitLabGraphQLClient(config);
// Log configuration on startup (for debugging)
if (process.env.NODE_ENV !== 'production') {
console.error('[MCP] Configuration loaded:');
console.error(` GitLab URL: ${config.gitlabUrl}`);
console.error(` Auth mode: ${config.authMode}`);
console.error(` Shared token: ${config.sharedAccessToken ? '✓ configured' : '✗ not set'}`);
console.error(` Max page size: ${config.maxPageSize}`);
console.error(` Timeout: ${config.defaultTimeout}ms`);
}
}
/**
* Create a new MCP Server instance with all handlers configured
*/
private createServer(): Server {
const server = new Server(
{
name: 'gitlab-mcp-server',
version: '1.7.0',
},
{
capabilities: {
tools: {},
prompts: {},
},
}
);
this.setupToolHandlers(server);
this.setupPromptHandlers(server);
server.onerror = (error) => {
console.error('[MCP Error]', error);
};
return server;
}
private setupToolHandlers(server: Server): void {
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools.map(tool => ({
name: tool.name,
...(tool.title && { title: tool.title }),
description: tool.description,
inputSchema: toJsonSchema(tool.inputSchema),
...(tool.outputSchema && { outputSchema: toJsonSchema(tool.outputSchema) }),
...(tool.annotations && { annotations: tool.annotations }),
...(tool.icon && { icon: tool.icon }),
})),
};
});
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const { name, arguments: args } = request.params;
const tool = tools.find(t => t.name === name);
if (!tool) {
throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`);
}
try {
const validatedInput = tool.inputSchema.parse(args || {});
// Extract user credentials: prioritize args, fallback to session-specific config
let userConfig = validatedInput.userCredentials;
// If no credentials in args, try to get from session context
if (!userConfig && extra?._meta?.sessionId) {
const sessionData = this.httpSessions.get(extra._meta.sessionId as string);
if (sessionData?.userConfig) {
userConfig = sessionData.userConfig;
}
}
delete validatedInput.userCredentials; // Remove from input to avoid passing to handler
const result = await tool.handler(validatedInput, this.gitlabClient, userConfig);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
if (error instanceof Error) {
throw new McpError(ErrorCode.InternalError, error.message);
}
throw new McpError(ErrorCode.InternalError, 'Unknown error occurred');
}
});
}
private setupPromptHandlers(server: Server): void {
// Define helpful prompts for common GitLab workflows
const prompts = [
{
name: 'explore-project',
title: 'Explore Project',
description: 'Explore a GitLab project structure and recent activity',
arguments: [
{
name: 'projectPath',
description: 'Full path of the project (e.g., "group/project-name")',
required: true,
},
],
},
{
name: 'find-my-work',
title: 'Find My Work',
description: 'Find issues and merge requests assigned to you',
arguments: [],
},
{
name: 'review-merge-request',
title: 'Review Merge Request',
description: 'Review a specific merge request with code changes',
arguments: [
{
name: 'projectPath',
description: 'Full path of the project (e.g., "group/project-name")',
required: true,
},
{
name: 'mrIid',
description: 'Merge request IID number',
required: true,
},
],
},
];
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return { prompts };
});
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'explore-project') {
const projectPath = args?.projectPath as string;
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please explore the GitLab project "${projectPath}". Show me:
1. Project overview and description
2. Recent issues (last 10)
3. Recent merge requests (last 10)
4. Repository structure (browse the root directory)
Provide direct links to all resources you find.`,
},
},
],
};
}
if (name === 'find-my-work') {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please find all issues and merge requests assigned to me. Search for:
1. Open issues assigned to me
2. Open merge requests where I'm assigned or a reviewer
3. Recently closed items from the last week
Provide direct links to each item and summarize the current state.`,
},
},
],
};
}
if (name === 'review-merge-request') {
const projectPath = args?.projectPath as string;
const mrIid = args?.mrIid as string;
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please review merge request !${mrIid} in project "${projectPath}". Show me:
1. MR title, description, and status
2. Source and target branches
3. Changed files (browse the repository at both refs if needed)
4. Related issues
5. Review comments and approvals
Provide the direct link to the MR and suggest any concerns or next steps.`,
},
},
],
};
}
throw new McpError(ErrorCode.InvalidRequest, `Unknown prompt: ${name}`);
});
}
private setupProcessHandlers(): void {
process.on('SIGINT', async () => {
if (this.sessionCleanupInterval) {
clearInterval(this.sessionCleanupInterval);
}
// Close all HTTP sessions
for (const [sessionId, data] of this.httpSessions.entries()) {
try {
await data.server.close();
} catch (e) {
// Ignore errors during shutdown
}
}
this.httpSessions.clear();
// Close stdio server if running
if (this.server) {
await this.server.close();
}
process.exit(0);
});
}
/**
* Start periodic cleanup of inactive sessions
*/
private startSessionCleanup(): void {
const SESSION_TIMEOUT = 10 * 60 * 1000; // 10 minutes (reduced from 30)
const CLEANUP_INTERVAL = 2 * 60 * 1000; // Check every 2 minutes (reduced from 5)
this.sessionCleanupInterval = setInterval(() => {
const now = Date.now();
const expiredSessions: string[] = [];
for (const [sessionId, data] of this.httpSessions.entries()) {
if (now - data.lastActivity > SESSION_TIMEOUT) {
expiredSessions.push(sessionId);
}
}
for (const sessionId of expiredSessions) {
const data = this.httpSessions.get(sessionId);
if (data) {
console.error(`[MCP] Session ${sessionId} expired due to inactivity`);
data.server.close().catch(() => {});
data.transport.close().catch(() => {});
this.httpSessions.delete(sessionId);
}
}
if (expiredSessions.length > 0) {
console.error(`[MCP] Cleaned up ${expiredSessions.length} expired session(s). Active sessions: ${this.httpSessions.size}`);
}
}, CLEANUP_INTERVAL);
}
/**
* Extract and validate user credentials from request headers
*/
private extractUserCredentials(req: express.Request): { accessToken: string; gitlabUrl?: string } | undefined {
const authHeader = (req.headers['authorization'] as string) || '';
const gitlabUrlHeader = (req.headers['x-gitlab-url'] as string) || undefined;
if (!authHeader) {
return undefined;
}
const token = authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length).trim()
: authHeader.trim();
if (!token) {
return undefined;
}
return { accessToken: token, gitlabUrl: gitlabUrlHeader };
}
/**
* Shared handler for Streamable HTTP requests (used by both / and /mcp endpoints)
*/
private async handleStreamableHTTP(req: express.Request, res: express.Response): Promise<void> {
try {
// Debug logging to understand what's happening
const hasBody = req.body && Object.keys(req.body).length > 0;
const bodyPreview = hasBody ? JSON.stringify(req.body).substring(0, 100) : 'empty';
// Validate Accept header per MCP spec
const acceptHeader = req.headers['accept'] || '';
const supportsJson = acceptHeader.includes('application/json');
const supportsSse = acceptHeader.includes('text/event-stream');
if (!supportsJson && !supportsSse) {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Accept header must include application/json or text/event-stream'
},
id: null,
});
return;
}
// Get session ID from header (check both lowercase and capitalized)
const sessionIdHeader = (req.headers['mcp-session-id'] as string) ||
(req.headers['Mcp-Session-Id'] as string) || '';
console.error(`[MCP] Request: ${req.method} session=${sessionIdHeader || 'none'} body=${bodyPreview}`);
if (sessionIdHeader && this.httpSessions.has(sessionIdHeader)) {
// Existing session: reuse transport and update credentials
const sessionData = this.httpSessions.get(sessionIdHeader)!;
const userConfig = this.extractUserCredentials(req);
// Update session-specific credentials if provided
if (userConfig) {
sessionData.userConfig = userConfig;
}
// Update last activity timestamp
sessionData.lastActivity = Date.now();
// Don't log every request, only session changes
await sessionData.transport.handleRequest(req as any, res as any, (req as any).body);
return;
}
// New session initialization
if (req.method === 'POST') {
// If we have too many sessions, clean up old ones immediately
if (this.httpSessions.size > 10) {
const now = Date.now();
const oldSessions: string[] = [];
// Find sessions older than 5 minutes
for (const [sessionId, data] of this.httpSessions.entries()) {
if (now - data.lastActivity > 5 * 60 * 1000) {
oldSessions.push(sessionId);
}
}
// Close and remove old sessions
for (const sessionId of oldSessions) {
const data = this.httpSessions.get(sessionId);
if (data) {
console.error(`[MCP] Force-closing old session ${sessionId}`);
data.server.close().catch(() => {});
data.transport.close().catch(() => {});
this.httpSessions.delete(sessionId);
}
}
if (oldSessions.length > 0) {
console.error(`[MCP] Emergency cleanup: removed ${oldSessions.length} old sessions (total now: ${this.httpSessions.size})`);
}
}
// Create a new Server instance for this session
const server = this.createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId: string) => {
const userConfig = this.extractUserCredentials(req);
this.httpSessions.set(sessionId, {
server,
transport,
userConfig,
lastActivity: Date.now()
});
console.error(`[MCP] Session ${sessionId} initialized with ${userConfig ? 'user' : 'shared'} credentials (total sessions: ${this.httpSessions.size})`);
},
});
transport.onclose = () => {
if (transport.sessionId) {
console.error(`[MCP] Session ${transport.sessionId} closed (remaining sessions: ${this.httpSessions.size - 1})`);
const sessionData = this.httpSessions.get(transport.sessionId);
if (sessionData) {
sessionData.server.close().catch(() => {});
}
this.httpSessions.delete(transport.sessionId);
}
};
// Handle errors on the transport
transport.onerror = (error: Error) => {
console.error(`[MCP] Transport error for session ${transport.sessionId}:`, error.message);
};
await server.connect(transport);
await transport.handleRequest(req as any, res as any, (req as any).body);
return;
}
// No valid session and not a POST request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided. Initialize with POST request.'
},
id: null,
});
} catch (error) {
console.error('[MCP] Error in Streamable HTTP endpoint:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
}
async run(): Promise<void> {
try {
const config = loadConfig();
// Try to introspect schema on startup if we have a shared token
if (config.sharedAccessToken) {
try {
await this.gitlabClient.introspectSchema();
console.error('GitLab GraphQL schema introspected successfully using shared token');
} catch (error) {
console.error('Warning: Failed to introspect schema with shared token:', error);
console.error('Schema will be introspected when user credentials are provided');
}
} else {
console.error('No shared access token provided. Schema will be introspected when user credentials are provided.');
}
// Determine transport based on environment
// Note: For Smithery TypeScript runtime, run in stdio; their wrapper provides HTTP.
const port = process.env.GITLAB_MCP_PORT ? parseInt(process.env.GITLAB_MCP_PORT) : null;
const useHttp = process.env.MCP_TRANSPORT === 'http';
if (useHttp && port) {
// Streamable HTTP transport for LibreChat and modern MCP clients
const app = express();
// Disable X-Powered-By header
app.disable('x-powered-by');
// Parse JSON bodies - but NOT for /message endpoint (SSE transport needs raw stream)
app.use((req, res, next) => {
if (req.path === '/message') {
// Skip JSON parsing for SSE message endpoint
next();
} else {
express.json()(req, res, next);
}
});
// CORS and headers
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-GitLab-Url, Mcp-Session-Id, Accept, Last-Event-ID, Cache-Control');
res.header('Access-Control-Expose-Headers', 'Mcp-Session-Id, MCP-Protocol-Version');
res.header('MCP-Protocol-Version', '2025-11-25');
// Disable buffering for SSE streams
if (req.headers.accept?.includes('text/event-stream')) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
}
if (req.method === 'OPTIONS') {
res.sendStatus(200);
return;
}
next();
});
// Streamable HTTP endpoint at root (primary transport)
app.all('/', (req, res) => this.handleStreamableHTTP(req, res));
// Alternative /mcp endpoint for container compatibility
app.all('/mcp', (req, res) => this.handleStreamableHTTP(req, res));
// DELETE support for explicit session termination (per MCP spec)
app.delete('/', async (req, res) => {
const sessionIdHeader = (req.headers['mcp-session-id'] as string) || '';
if (sessionIdHeader && this.httpSessions.has(sessionIdHeader)) {
const sessionData = this.httpSessions.get(sessionIdHeader)!;
await sessionData.server.close();
await sessionData.transport.close();
this.httpSessions.delete(sessionIdHeader);
console.error(`[MCP] Session ${sessionIdHeader} terminated by client`);
res.sendStatus(200);
} else {
res.status(404).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Session not found' },
id: null,
});
}
});
app.delete('/mcp', async (req, res) => {
const sessionIdHeader = (req.headers['mcp-session-id'] as string) || '';
if (sessionIdHeader && this.httpSessions.has(sessionIdHeader)) {
const sessionData = this.httpSessions.get(sessionIdHeader)!;
await sessionData.server.close();
await sessionData.transport.close();
this.httpSessions.delete(sessionIdHeader);
console.error(`[MCP] Session ${sessionIdHeader} terminated by client`);
res.sendStatus(200);
} else {
res.status(404).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Session not found' },
id: null,
});
}
});
// Container-runtime compatibility: serve config schema
app.get('/.well-known/mcp-config', (req, res) => {
res.set('Content-Type', 'application/schema+json; charset=utf-8');
const baseSchema = toJsonSchema(configSchema);
const configJsonSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: `${req.protocol}://${req.get('host')}/.well-known/mcp-config`,
title: 'MCP Session Configuration',
description: 'Schema for the /mcp endpoint configuration',
'x-mcp-version': '1.0',
'x-query-style': 'dot+bracket',
...baseSchema,
} as any;
res.json(configJsonSchema);
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
sessions: this.httpSessions.size,
protocol: '2025-11-25'
});
});
app.listen(port, () => {
console.error('='.repeat(60));
console.error('GitLab MCP Server - HTTP Mode');
console.error('='.repeat(60));
console.error(`Server: http://localhost:${port}`);
console.error(`Streamable HTTP: http://localhost:${port}/ (recommended)`);
console.error(`Alternative: http://localhost:${port}/mcp`);
console.error(`Health check: http://localhost:${port}/health`);
console.error(`Protocol: MCP 2025-11-25`);
console.error('');
console.error('Configuration:');
console.error(` Auth mode: ${loadConfig().authMode}`);
console.error(` GitLab URL: ${loadConfig().gitlabUrl}`);
console.error(` Session cleanup: 10min timeout, checked every 2min`);
console.error('');
console.error('For LibreChat: Use streamable-http transport in librechat.yml');
console.error('='.repeat(60));
// Start session cleanup
this.startSessionCleanup();
});
} else {
// Default to stdio transport
const config = loadConfig();
console.error('='.repeat(60));
console.error('GitLab MCP Server - stdio Mode');
console.error('='.repeat(60));
console.error('Transport: stdio (for Claude Desktop, Claude Code, VS Code)');
console.error(`Protocol: MCP 2025-11-25`);
console.error('');
console.error('Configuration:');
console.error(` Auth mode: ${config.authMode}`);
console.error(` GitLab URL: ${config.gitlabUrl}`);
console.error(` Shared token: ${config.sharedAccessToken ? '✓ configured' : '✗ not set'}`);
console.error('');
console.error('Tip: Set GITLAB_MCP_PORT to enable HTTP mode for LibreChat');
console.error('='.repeat(60));
// Create server for stdio mode
this.server = this.createServer();
const transport = new StdioServerTransport();
await this.server.connect(transport);
}
// Setup process signal handlers
this.setupProcessHandlers();
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
}
// Smithery TypeScript runtime expects a default export that returns an MCP Server instance.
// This factory prepares the server with all handlers but does not bind transports.
export default function createMcpServer(_args: { sessionId: string; config: unknown }): Server {
const instance = new GitLabMCPServer();
// Create and return a new Server instance; the host (e.g., Smithery) will call connect(transport)
// and manage the Streamable HTTP session lifecycle.
// @ts-ignore accessing private for integration factory
return (instance as any).createServer() as Server;
}
// Optional: expose a (currently empty) config schema for /.well-known/mcp-config
export const configSchema = z.object({});
// Run in CLI mode only if this file is the program entry-point
const isMain = (() => {
try {
const thisFile = fileURLToPath(import.meta.url);
return process.argv[1] && thisFile === process.argv[1];
} catch {
return false;
}
})();
if (isMain) {
const cli = new GitLabMCPServer();
cli.run().catch((error) => {
console.error('Server failed:', error);
process.exit(1);
});
}