From dce245967d60dbc3c2a8f9e01594148ad82d6e06 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 9 Sep 2026 21:57:43 +0000 Subject: [PATCH] refactor: use MCP SDK for GitLab tools --- .../api/src/handlers/mcp/gitlab/index.test.ts | 111 ++-- apps/api/src/handlers/mcp/gitlab/index.ts | 502 +----------------- .../api/src/handlers/mcp/gitlab/operations.ts | 382 +++++++++++++ apps/api/src/handlers/mcp/gitlab/schemas.ts | 120 +++++ apps/api/src/handlers/mcp/gitlab/tools.ts | 61 +++ 5 files changed, 653 insertions(+), 523 deletions(-) create mode 100644 apps/api/src/handlers/mcp/gitlab/operations.ts create mode 100644 apps/api/src/handlers/mcp/gitlab/schemas.ts create mode 100644 apps/api/src/handlers/mcp/gitlab/tools.ts diff --git a/apps/api/src/handlers/mcp/gitlab/index.test.ts b/apps/api/src/handlers/mcp/gitlab/index.test.ts index b9eb74805..a2d9122af 100644 --- a/apps/api/src/handlers/mcp/gitlab/index.test.ts +++ b/apps/api/src/handlers/mcp/gitlab/index.test.ts @@ -76,15 +76,32 @@ function request( params: unknown = {}, auth?: Variables['authContext'], ) { + const requestParams = + method === 'initialize' && + params && + typeof params === 'object' && + Object.keys(params).length === 0 + ? { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'gitlab-mcp-test', version: '1.0.0' }, + } + : params; return app(auth).request('/gitlab', { method: 'POST', headers: { + accept: 'application/json, text/event-stream', 'content-type': 'application/json', 'mcp-session-id': 'attacker-session', 'X-GitLab-API-URL': 'https://attacker.invalid', Authorization: 'Bearer inbound-secret', }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: requestParams, + }), }); } function call(name = 'get_merge_request', args: Record = {}) { @@ -111,6 +128,13 @@ async function payload(response: Response) { expect(response.status).toBe(200); return JSON.parse((await response.json()).result.content[0].text); } +async function expectMcpError(response: Response, message?: string) { + expect(response.status).toBe(200); + const body = await response.clone().json(); + expect(body.error || body.result?.isError).toBeTruthy(); + if (message) expect(JSON.stringify(body)).toContain(message); + return body; +} beforeEach(async () => { previousSecret = ( await db.query.deploymentSecrets.findFirst({ @@ -214,7 +238,10 @@ describe.each(['/gitlab', '/gitlab/'])('mounted routing %s', (path) => { const send = (requestPath = path) => mounted.request(`/api/mcp-routing${requestPath}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, @@ -336,7 +363,7 @@ it.each([ ] as const)( 'rejects forbidden %s arguments %j before HTTP', async (name, args) => { - expect((await mrCall(name, args)).status).toBe(400); + await expectMcpError(await mrCall(name, args)); expect(traffic).toEqual([]); }, ); @@ -372,11 +399,11 @@ it('uses freshly resolved OAuth and canonical IDs, never caller headers or metad await request('tools/call', { name: 'get_merge_request', arguments: { project_id: externalId, merge_request_iid: '7' }, - _meta: {}, + _meta: { source: 'ignored' }, }) ).status, - ).toBe(400); - expect(mocks.token).toHaveBeenCalledTimes(2); + ).toBe(200); + expect(mocks.token).toHaveBeenCalledTimes(3); }); it('refreshes the real encrypted OAuth connection and preserves self-managed API prefixes', async () => { @@ -440,7 +467,7 @@ it('rejects removed connections and scope loss during refresh', async () => { scope: 'read_api', expires_in: 7200, }); - expect((await mrCall()).status).toBe(400); + await expectMcpError(await mrCall()); expect(traffic).toHaveLength(1); expect(traffic[0]?.url).toBe('https://gitlab.example/oauth/token'); }); @@ -475,14 +502,12 @@ it.each([ ...(discussion.notes as unknown[]), { noteable_id: 70, noteable_iid: 8, noteable_type: 'MergeRequest' }, ]; - expect( - ( - await mrCall('create_merge_request_discussion_note', { - discussion_id: 'thread', - body: 'hello', - }) - ).status, - ).toBe(400); + await expectMcpError( + await mrCall('create_merge_request_discussion_note', { + discussion_id: 'thread', + body: 'hello', + }), + ); expect(traffic.every((item) => item.init?.method === 'GET')).toBe(true); }, ); @@ -490,14 +515,12 @@ it.each(['update_merge_request', 'create_merge_request_note'])( 'checks project ownership for %s, not only replies', async (name) => { mrObject.project_id = 1; - expect( - ( - await mrCall( - name, - name === 'update_merge_request' ? { title: 'new' } : { body: 'new' }, - ) - ).status, - ).toBe(400); + await expectMcpError( + await mrCall( + name, + name === 'update_merge_request' ? { title: 'new' } : { body: 'new' }, + ), + ); expect(traffic).toHaveLength(1); expect(traffic[0]?.init?.method).toBe('GET'); }, @@ -672,10 +695,9 @@ it('exposes numeric and keyset continuation without following any response URL', ), ), ).toBe(true); - expect( - (await call('get_repository_tree', { page_token: 'https://evil.invalid' })) - .status, - ).toBe(400); + await expectMcpError( + await call('get_repository_tree', { page_token: 'https://evil.invalid' }), + ); }); it.each([400, 403, 404, 405, 501])( 'returns a clear unavailable search error (%s), never broadens scope', @@ -683,7 +705,7 @@ it.each([400, 403, 404, 405, 501])( providerResponse = () => new Response('refresh-secret private error', { status }); const response = await call('search_project_code', { search: 'test' }); - expect(response.status).toBe(400); + await expectMcpError(response, 'Project code search is unavailable'); const text = await response.text(); expect(text).toContain('Project code search is unavailable'); expect(text).not.toContain('refresh-secret'); @@ -694,9 +716,9 @@ it.each([400, 403, 404, 405, 501])( it('supports only initialize, notification, list and call RPC methods', async () => { expect( (await (await request('initialize')).json()).result.capabilities, - ).toEqual({ tools: {} }); - expect((await request('notifications/initialized')).status).toBe(202); - expect((await request('resources/list')).status).toBe(400); + ).toEqual({ tools: { listChanged: true } }); + expect((await request('notifications/initialized')).status).toBe(200); + await expectMcpError(await request('resources/list')); expect(traffic).toEqual([]); }); it('bounds a provider stall to the request deadline and suppresses its error', async () => { @@ -714,7 +736,7 @@ it('bounds a provider stall to the request deadline and suppresses its error', a ), ); const response = await mrCall(); - expect(response.status).toBe(400); + await expectMcpError(response); expect(await response.text()).not.toContain('timeout secret'); }, 30000); it.each([301, 302, 307, 308, 401, 500])( @@ -726,7 +748,7 @@ it.each([301, 302, 307, 308, 401, 500])( headers: { location: 'https://evil.invalid' }, }); const response = await mrCall(); - expect(response.status).toBe(400); + await expectMcpError(response); expect(await response.text()).not.toContain('refreshed-oauth-token'); expect(traffic).toHaveLength(1); expect(traffic[0]?.init?.redirect).toBe('error'); @@ -740,10 +762,10 @@ it('bounds request and serialized response bytes, including JSON escaping', asyn expect(traffic).toEqual([]); providerResponse = () => Response.json({ ...mrObject, description: 'x'.repeat(MAX_BYTES) }); - expect((await mrCall()).status).toBe(400); + await expectMcpError(await mrCall()); fileResponse = () => new Response('"'.repeat(300000)); providerResponse = undefined; - expect((await readFile()).status).toBe(400); + await expectMcpError(await readFile()); }); const MAX_BYTES = 1024 * 1024; it.each(['refreshed-oauth-token', 'refresh-secret', 'client-secret'])( @@ -752,7 +774,7 @@ it.each(['refreshed-oauth-token', 'refresh-secret', 'client-secret'])( providerResponse = () => Response.json({ ...mrObject, description: secret }); const response = await mrCall(); - expect(response.status).toBe(400); + await expectMcpError(response); expect(await response.text()).not.toContain(secret); }, ); @@ -762,7 +784,7 @@ it('validates bounded page responses rather than reporting malformed or oversize Array.from({ length: 21 }, () => ({})), ]) { providerResponse = () => Response.json(value); - expect((await call('list_commits')).status).toBe(400); + await expectMcpError(await call('list_commits')); } }); @@ -771,7 +793,7 @@ it('suppresses JSON-escaped credentials before wrapping the MCP text result', as providerResponse = () => Response.json({ ...mrObject, description: connection.clientSecret }); const response = await mrCall(); - expect(response.status).toBe(400); + await expectMcpError(response); expect(await response.text()).not.toContain('quoted-'); }); @@ -810,7 +832,7 @@ it.each([ { url: 'https://evil.invalid' }, { headers: {} }, ])('rejects unsafe file arguments %j without HTTP', async (args) => { - expect((await readFile(args)).status).toBe(400); + await expectMcpError(await readFile(args)); expect(traffic).toEqual([]); }); it('returns default truncation and explicit continuation without newline normalization', async () => { @@ -860,7 +882,7 @@ it('cancels oversized streams even for a one-line window without returning parti }), ); const response = await readFile({ limit: 1 }); - expect(response.status).toBe(400); + await expectMcpError(response, '1 MiB'); expect(await response.text()).toContain('1 MiB'); expect(cancelled).toBe(true); expect(pulled).toBeLessThan(20); @@ -871,10 +893,10 @@ it('rejects declared oversize and counts UTF-8 bytes, while accepting the exact new Response(new ReadableStream({ cancel }), { headers: { 'content-length': String(MAX_BYTES + 1) }, }); - expect((await readFile()).status).toBe(400); + await expectMcpError(await readFile()); expect(cancel).toHaveBeenCalled(); fileResponse = () => new Response('\u00e9'.repeat(524289)); - expect((await readFile({ limit: 1 })).status).toBe(400); + await expectMcpError(await readFile({ limit: 1 })); fileResponse = () => new Response('a\n'.repeat(524288)); expect((await readFile({ limit: 1 })).status).toBe(200); }); @@ -883,9 +905,6 @@ it.each([new Uint8Array([0xff, 0xfe]), new Uint8Array([65, 0, 66])])( async (bytes) => { fileResponse = () => new Response(bytes); const response = await readFile(); - expect(response.status).toBe(400); - expect((await response.json()).error.message).toContain( - 'No file content was returned', - ); + await expectMcpError(response, 'No file content was returned'); }, ); diff --git a/apps/api/src/handlers/mcp/gitlab/index.ts b/apps/api/src/handlers/mcp/gitlab/index.ts index d38a81c17..fe9d956d9 100644 --- a/apps/api/src/handlers/mcp/gitlab/index.ts +++ b/apps/api/src/handlers/mcp/gitlab/index.ts @@ -1,500 +1,48 @@ import { Hono } from 'hono'; import { bodyLimit } from 'hono/body-limit'; -import { z } from 'zod/v4'; -import { db, and, eq, isNull, repositories, users } from '@roomote/db/server'; -import { - buildGitLabApiBaseUrl, - createGitLabMergeRequestNote, - getGitLabMergeRequest, - getGitLabOAuthConnection, - GitLabApiError, - normalizeGitLabBaseUrl, - requestGitLab, - resolveGitLabBaseUrl, - resolveGitLabOAuthAccessToken, -} from '@roomote/gitlab'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; + import type { Variables } from '../../../types'; +import { authorizeGitLabMcp, GitLabOperationError } from './operations'; +import { gitLabToolSchemas } from './schemas'; +import { registerGitLabTools } from './tools'; + +const GITLAB_MCP_SERVER_INFO = { + name: 'roomote-gitlab', + version: '1.0.0', +} as const; -const MAX_BYTES = 1024 * 1024; -class OperationError extends Error {} -const id = z - .string() - .regex(/^[1-9][0-9]*$/) - .refine((value) => Number.isSafeInteger(Number(value))); -const project = z.union([ - id, - z.number().int().positive(), - z.string().regex(/^[\w.-]+(?:\/[\w.-]+)+$/), -]); -const text = z.string().min(1); -const path = text.refine( - (value) => - !/[\\\x00-\x1f\x7f]/.test(value) && - value - .split('/') - .every((part) => part !== '' && part !== '.' && part !== '..'), -); -const pagination = { - page: z.number().int().min(1).optional(), - per_page: z.number().int().min(1).max(100).optional(), -}; -const pageToken = z - .string() - .min(1) - .regex(/^[A-Za-z0-9_+/=-]+$/); -const mr = { project_id: project, merge_request_iid: id }; -export const schemas = { - get_file_contents: z.strictObject({ - project_id: project, - file_path: path, - ref: z - .string() - .regex(/^[a-fA-F0-9]{40}$/) - .describe( - 'Full immutable commit SHA. Resolve a branch with get_commit first.', - ), - offset: z - .number() - .int() - .min(0) - .optional() - .describe('Zero-based line offset; default 0.'), - limit: z - .number() - .int() - .min(1) - .max(2000) - .optional() - .describe( - 'Maximum lines; default 2000. Files over 1 MiB are rejected even for a small window.', - ), - }), - get_repository_tree: z.strictObject({ - project_id: project, - path: path.optional(), - ref: text.optional(), - recursive: z.boolean().optional(), - per_page: pagination.per_page, - page_token: pageToken.optional(), - pagination: z - .literal('keyset') - .optional() - .describe( - 'Keyset pagination; pass next_page_token as page_token to continue.', - ), - }), - search_project_code: z.strictObject({ - project_id: project, - search: text, - ref: text.optional(), - ...pagination, - }), - list_commits: z.strictObject({ - project_id: project, - ref_name: text.optional(), - path: path.optional(), - ...pagination, - }), - get_commit: z.strictObject({ project_id: project, sha: path }), - get_merge_request: z.strictObject(mr), - list_merge_request_diffs: z.strictObject({ ...mr, ...pagination }), - get_merge_request_notes: z.strictObject({ ...mr, ...pagination }), - mr_discussions: z.strictObject({ ...mr, ...pagination }), - update_merge_request: z.strictObject({ - ...mr, - title: text.optional(), - description: z.string().optional(), - state_event: z.enum(['close', 'reopen']).optional(), - }), - create_merge_request_note: z.strictObject({ ...mr, body: text }), - create_merge_request_discussion_note: z.strictObject({ - ...mr, - discussion_id: z.string().regex(/^[a-zA-Z0-9_-]+$/), - body: text, - }), -}; -type ToolName = keyof typeof schemas; -const isToolName = (name: string): name is ToolName => - Object.hasOwn(schemas, name); -const writes = new Set([ - 'update_merge_request', - 'create_merge_request_note', - 'create_merge_request_discussion_note', -]); +export const schemas = gitLabToolSchemas; export function createGitlabMcp() { const app = new Hono<{ Variables: Variables }>(); app.use('*', bodyLimit({ maxSize: 65536 })); - app.post('/', async (c) => { - const auth = c.get('authContext'); - if (!auth || auth.tokenType !== 'auth' || 'runId' in auth || !auth.userId) - return c.json({ error: 'User authentication required' }, 403); - const actor = await db.query.users.findFirst({ - where: and(eq(users.id, auth.userId), isNull(users.deletedAt)), - }); - if (!actor || !['member', 'admin'].includes(actor.role)) - return c.json({ error: 'Active member required' }, 403); - let requestId: string | number | null = null; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 20_000); + app.on(['POST', 'GET', 'DELETE'], '/', async (c) => { try { - const rpc = z - .strictObject({ - jsonrpc: z.literal('2.0'), - id: z.union([z.string(), z.number()]).optional(), - method: z.string(), - params: z.unknown().optional(), - }) - .parse(await c.req.json()); - requestId = rpc.id ?? null; - if (rpc.method === 'notifications/initialized') return c.body(null, 202); - if (rpc.method === 'initialize') - return c.json({ - jsonrpc: '2.0', - id: requestId, - result: { - protocolVersion: '2025-03-26', - capabilities: { tools: {} }, - serverInfo: { name: 'roomote-gitlab', version: '1.0.0' }, - }, - }); - if (!['tools/list', 'tools/call'].includes(rpc.method)) - throw new Error('Unsupported method'); - const call = - rpc.method === 'tools/call' - ? z - .strictObject({ name: z.string(), arguments: z.unknown() }) - .parse(rpc.params) - : undefined; - if (!call) z.strictObject({}).parse(rpc.params ?? {}); - if (call && !isToolName(call.name)) throw new Error('Unsupported tool'); - const args: Record | undefined = - call && isToolName(call.name) - ? schemas[call.name].parse(call.arguments) - : undefined; - if ( - call?.name === 'update_merge_request' && - args && - !['title', 'description', 'state_event'].some( - (key) => args[key] !== undefined, - ) - ) - throw new Error('Empty update'); - const baseUrl = await resolveGitLabBaseUrl(); - const base = new URL(baseUrl); - if ( - !['https:', 'http:'].includes(base.protocol) || - base.username || - base.password || - base.search || - base.hash - ) - throw new Error('Invalid configuration'); - const checkConnection = ( - connection: Awaited>, - ) => { - if ( - !connection || - connection.status !== 'active' || - !connection.scopes.includes('api') || - normalizeGitLabBaseUrl(connection.baseUrl) !== baseUrl - ) - throw new Error('OAuth unavailable'); - return connection; - }; - const connection = checkConnection(await getGitLabOAuthConnection()); - const value = args ? String(args.project_id) : undefined; - const repo = await db.query.repositories.findFirst({ - where: and( - eq(repositories.sourceControlProvider, 'gitlab'), - eq(repositories.host, base.host), - eq(repositories.isActive, true), - value === undefined - ? undefined - : /^[1-9][0-9]*$/.test(value) - ? eq(repositories.externalRepoId, value) - : eq(repositories.fullName, value), - ), + const context = await authorizeGitLabMcp(c.get('authContext')); + const server = new McpServer(GITLAB_MCP_SERVER_INFO); + registerGitLabTools(server, context); + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, }); - if (!repo) throw new Error('Repository unavailable'); - const projectId = id.parse(repo.externalRepoId); - if (!call || !args) - return c.json({ - jsonrpc: '2.0', - id: requestId, - result: { - tools: Object.entries(schemas).map(([name, schema]) => ({ - name, - description: - name === 'get_file_contents' - ? 'Read a UTF-8 file at an immutable commit, at most 1 MiB and 2000 lines, with continuation metadata.' - : name === 'search_project_code' - ? 'Search code in this connected project only. Requires instance support for blob search; no unscoped fallback.' - : `GitLab ${name.replaceAll('_', ' ')} in an active connected repository.`, - inputSchema: z.toJSONSchema(schema), - annotations: { - readOnlyHint: !writes.has(name as ToolName), - destructiveHint: writes.has(name as ToolName), - openWorldHint: true, - }, - })), - }, - }); - const token = await resolveGitLabOAuthAccessToken({ - requestTimeoutMs: 10000, - }); - if (!token) throw new Error('OAuth unavailable'); - const refreshedConnection = checkConnection( - await getGitLabOAuthConnection(), - ); - controller.signal.throwIfAborted(); - const options = { - apiBaseUrl: buildGitLabApiBaseUrl(baseUrl), - token, - bounded: true, - signal: controller.signal, - }; - const root = `/projects/${projectId}`; - const mrPath = `${root}/merge_requests/${args.merge_request_iid}`; - const mrOptions = { - ...options, - projectId, - mergeRequestIid: Number(args.merge_request_iid), - }; - const read = async (suffix: string) => - ( - await requestGitLab({ ...options, path: mrPath + suffix }, [200]) - ).json(); - if (writes.has(call.name as ToolName)) { - const details = await getGitLabMergeRequest(mrOptions); - if ( - String(details.project_id) !== projectId || - String(details.iid) !== args.merge_request_iid || - !Number.isSafeInteger(details.id) || - Number(details.id) <= 0 - ) - throw new Error('Ownership mismatch'); - if (args.discussion_id) { - const discussion = await read(`/discussions/${args.discussion_id}`); - if ( - discussion.id !== args.discussion_id || - !Array.isArray(discussion.notes) || - !discussion.notes.length || - !discussion.notes.every( - (note: Record) => - note.noteable_type === 'MergeRequest' && - note.noteable_id === details.id && - String(note.noteable_iid) === args.merge_request_iid, - ) - ) - throw new Error('Ownership mismatch'); - } - } - let payload: unknown; - if (call.name === 'get_file_contents') { - const input = schemas.get_file_contents.parse(args); - let content: string; - try { - const response = await requestGitLab( - { - ...options, - path: `${root}/repository/files/${encodeURIComponent(input.file_path)}/raw`, - params: { ref: input.ref, lfs: false }, - accept: 'text/plain', - }, - [200], - ); - // Response.text() strips a BOM; keep the original file bytes here. - content = new TextDecoder('utf-8', { - fatal: true, - ignoreBOM: true, - }).decode(await response.arrayBuffer()); - if (content.includes('\0')) throw new Error('Binary file'); - } catch { - throw new OperationError( - 'File read failed: the file may be unavailable, exceed the 1 MiB limit, or not be valid UTF-8 text. No file content was returned.', - ); - } - const lines = content.match(/[^\n]*\n|[^\n]+$/g) ?? []; - const offset = input.offset ?? 0; - const selected = lines.slice(offset, offset + (input.limit ?? 2000)); - const nextOffset = Math.min(offset + selected.length, lines.length); - payload = { - project_id: projectId, - file_path: input.file_path, - ref: input.ref, - size_bytes: Buffer.byteLength(content), - total_lines: lines.length, - offset, - lines_returned: selected.length, - next_offset: nextOffset < lines.length ? nextOffset : null, - truncated: offset > 0 || nextOffset < lines.length, - content: selected.join(''), - }; - } else if (call.name === 'get_merge_request') { - payload = await getGitLabMergeRequest(mrOptions); - } else if (call.name === 'create_merge_request_note') { - payload = await createGitLabMergeRequestNote({ - ...mrOptions, - body: String(args.body), - }); - } else { - let path: string; - let method: 'GET' | 'POST' | 'PUT' = 'GET'; - let body: Record | undefined; - const params: Record = {}; - const paged = [ - 'get_repository_tree', - 'search_project_code', - 'list_commits', - 'list_merge_request_diffs', - 'get_merge_request_notes', - 'mr_discussions', - ].includes(call.name); - if (paged) { - params.per_page = Number(args.per_page ?? 20); - if (call.name !== 'get_repository_tree') - params.page = Number(args.page ?? 1); - } - switch (call.name) { - case 'get_repository_tree': - path = `${root}/repository/tree`; - params.pagination = 'keyset'; - break; - case 'search_project_code': - path = `${root}/search`; - params.scope = 'blobs'; - break; - case 'list_commits': - path = `${root}/repository/commits`; - break; - case 'get_commit': - path = `${root}/repository/commits/${encodeURIComponent(String(args.sha))}`; - break; - case 'list_merge_request_diffs': - path = `${mrPath}/diffs`; - break; - case 'get_merge_request_notes': - path = `${mrPath}/notes`; - break; - case 'mr_discussions': - path = `${mrPath}/discussions`; - break; - case 'update_merge_request': - path = mrPath; - method = 'PUT'; - body = Object.fromEntries( - ['title', 'description', 'state_event'] - .filter((key) => args[key] !== undefined) - .map((key) => [key, args[key]]), - ); - break; - case 'create_merge_request_discussion_note': - path = `${mrPath}/discussions/${args.discussion_id}/notes`; - method = 'POST'; - body = { body: args.body }; - break; - default: - throw new Error('Unsupported tool'); - } - for (const key of [ - 'path', - 'ref', - 'recursive', - 'page_token', - 'search', - 'ref_name', - ]) { - const value = args[key]; - if (typeof value === 'string' || typeof value === 'boolean') - params[key] = value; - } - let response: Response; - try { - response = await requestGitLab( - { ...options, path, params, method, body }, - [200, 201], - ); - } catch (error) { - if ( - call.name === 'search_project_code' && - error instanceof GitLabApiError && - [400, 403, 404, 405, 501].includes(error.status) - ) - throw new OperationError( - 'Project code search is unavailable on this GitLab instance or for this connection. No unscoped search was attempted.', - ); - throw error; - } - const data: unknown = await response.json(); - if (paged) { - if (!Array.isArray(data) || data.length > Number(params.per_page)) - throw new Error('Invalid page'); - if (call.name === 'get_repository_tree') { - // Extract only the cursor. Never fetch or expose provider-supplied URLs. - const link = response.headers - .get('link') - ?.split(',') - .find((part) => /;\s*rel="next"/.test(part)); - const next = link?.match(/<([^>]+)>/)?.[1]; - const cursor = next - ? new URL(next, options.apiBaseUrl).searchParams.get('page_token') - : null; - payload = { - items: data, - next_page_token: cursor ? pageToken.parse(cursor) : null, - }; - } else { - const next = response.headers.get('x-next-page'); - payload = { - items: data, - next_page: next ? Number(id.parse(next)) : null, - }; - } - } else payload = data; - } - const text = JSON.stringify(payload); - const result = { content: [{ type: 'text', text }] }; - const envelope = { jsonrpc: '2.0', id: requestId, result }; - const serialized = JSON.stringify(envelope); - const secrets = [ - token, - connection.accessToken, - connection.refreshToken, - connection.clientSecret, - refreshedConnection.accessToken, - refreshedConnection.refreshToken, - refreshedConnection.clientSecret, - ]; - if ( - Buffer.byteLength(serialized) > MAX_BYTES || - secrets.some( - (secret) => - secret && text.includes(JSON.stringify(secret).slice(1, -1)), - ) - ) - throw new OperationError( - 'GitLab output exceeds the response limit or cannot be returned safely. Request a smaller page or line window. No successful result was received.', - ); - return c.json(envelope); + await server.connect(transport); + return await transport.handleRequest(c.req.raw); } catch (error) { - return c.json( + return Response.json( { jsonrpc: '2.0', - id: requestId, + id: null, error: { code: -32000, message: - error instanceof OperationError + error instanceof GitLabOperationError ? error.message - : 'GitLab operation unavailable, unsupported, or outside the permitted scope. No successful result was received.', + : 'GitLab MCP unavailable', }, }, - 400, + { status: error instanceof GitLabOperationError ? 403 : 400 }, ); - } finally { - controller.abort(); - clearTimeout(timer); } }); return app; diff --git a/apps/api/src/handlers/mcp/gitlab/operations.ts b/apps/api/src/handlers/mcp/gitlab/operations.ts new file mode 100644 index 000000000..416069b38 --- /dev/null +++ b/apps/api/src/handlers/mcp/gitlab/operations.ts @@ -0,0 +1,382 @@ +import { and, db, eq, isNull, repositories, users } from '@roomote/db/server'; +import { + buildGitLabApiBaseUrl, + createGitLabMergeRequestNote, + getGitLabMergeRequest, + getGitLabOAuthConnection, + GitLabApiError, + normalizeGitLabBaseUrl, + requestGitLab, + resolveGitLabBaseUrl, + resolveGitLabOAuthAccessToken, +} from '@roomote/gitlab'; + +import type { Variables } from '../../../types'; +import { + gitLabPageTokenSchema, + gitLabToolSchemas, + gitLabWriteTools, + type GitLabToolInput, + type GitLabToolName, +} from './schemas'; + +const MAX_BYTES = 1024 * 1024; + +export class GitLabOperationError extends Error {} + +export type GitLabMcpContext = { + baseUrl: string; + host: string; +}; + +function checkConnection( + connection: Awaited>, + baseUrl: string, +) { + if ( + !connection || + connection.status !== 'active' || + !connection.scopes.includes('api') || + normalizeGitLabBaseUrl(connection.baseUrl) !== baseUrl + ) { + throw new Error('OAuth unavailable'); + } + return connection; +} + +export async function authorizeGitLabMcp( + auth: Variables['authContext'], +): Promise { + if (!auth || auth.tokenType !== 'auth' || 'runId' in auth || !auth.userId) { + throw new GitLabOperationError('User authentication required'); + } + + const actor = await db.query.users.findFirst({ + where: and(eq(users.id, auth.userId), isNull(users.deletedAt)), + }); + if (!actor || !['member', 'admin'].includes(actor.role)) { + throw new GitLabOperationError('Active member required'); + } + + const baseUrl = await resolveGitLabBaseUrl(); + const base = new URL(baseUrl); + if ( + !['https:', 'http:'].includes(base.protocol) || + base.username || + base.password || + base.search || + base.hash + ) { + throw new Error('Invalid configuration'); + } + + checkConnection(await getGitLabOAuthConnection(), baseUrl); + const repository = await db.query.repositories.findFirst({ + where: and( + eq(repositories.sourceControlProvider, 'gitlab'), + eq(repositories.host, base.host), + eq(repositories.isActive, true), + ), + }); + if (!repository) throw new Error('Repository unavailable'); + + return { baseUrl, host: base.host }; +} + +function safeToolResult(payload: unknown, secrets: Array) { + const text = JSON.stringify(payload); + const result = { content: [{ type: 'text' as const, text }] }; + const envelope = JSON.stringify({ jsonrpc: '2.0', id: null, result }); + + if ( + Buffer.byteLength(envelope) > MAX_BYTES || + secrets.some( + (secret) => secret && text.includes(JSON.stringify(secret).slice(1, -1)), + ) + ) { + throw new GitLabOperationError( + 'GitLab output exceeds the response limit or cannot be returned safely. Request a smaller page or line window. No successful result was received.', + ); + } + + return result; +} + +export async function executeGitLabTool( + context: GitLabMcpContext, + name: GitLabToolName, + input: GitLabToolInput, +) { + const args = input as Record; + const value = String(args.project_id); + const repo = await db.query.repositories.findFirst({ + where: and( + eq(repositories.sourceControlProvider, 'gitlab'), + eq(repositories.host, context.host), + eq(repositories.isActive, true), + /^[1-9][0-9]*$/.test(value) + ? eq(repositories.externalRepoId, value) + : eq(repositories.fullName, value), + ), + }); + if (!repo) throw new Error('Repository unavailable'); + + const projectId = + gitLabToolSchemas.get_merge_request.shape.merge_request_iid.parse( + repo.externalRepoId, + ); + const connection = checkConnection( + await getGitLabOAuthConnection(), + context.baseUrl, + ); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20_000); + + try { + const token = await resolveGitLabOAuthAccessToken({ + requestTimeoutMs: 10000, + }); + if (!token) throw new Error('OAuth unavailable'); + const refreshedConnection = checkConnection( + await getGitLabOAuthConnection(), + context.baseUrl, + ); + controller.signal.throwIfAborted(); + + const options = { + apiBaseUrl: buildGitLabApiBaseUrl(context.baseUrl), + token, + bounded: true, + signal: controller.signal, + }; + const root = `/projects/${projectId}`; + const mrPath = `${root}/merge_requests/${args.merge_request_iid}`; + const mrOptions = { + ...options, + projectId, + mergeRequestIid: Number(args.merge_request_iid), + }; + const read = async (suffix: string) => + ( + await requestGitLab({ ...options, path: mrPath + suffix }, [200]) + ).json(); + + if (gitLabWriteTools.has(name)) { + const details = await getGitLabMergeRequest(mrOptions); + if ( + String(details.project_id) !== projectId || + String(details.iid) !== args.merge_request_iid || + !Number.isSafeInteger(details.id) || + Number(details.id) <= 0 + ) { + throw new Error('Ownership mismatch'); + } + if (args.discussion_id) { + const discussion = await read(`/discussions/${args.discussion_id}`); + if ( + discussion.id !== args.discussion_id || + !Array.isArray(discussion.notes) || + !discussion.notes.length || + !discussion.notes.every( + (note: Record) => + note.noteable_type === 'MergeRequest' && + note.noteable_id === details.id && + String(note.noteable_iid) === args.merge_request_iid, + ) + ) { + throw new Error('Ownership mismatch'); + } + } + } + + let payload: unknown; + if (name === 'get_file_contents') { + const fileInput = gitLabToolSchemas.get_file_contents.parse(args); + let content: string; + try { + const response = await requestGitLab( + { + ...options, + path: `${root}/repository/files/${encodeURIComponent(fileInput.file_path)}/raw`, + params: { ref: fileInput.ref, lfs: false }, + accept: 'text/plain', + }, + [200], + ); + // Response.text() strips a BOM; keep the original file bytes here. + content = new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(await response.arrayBuffer()); + if (content.includes('\0')) throw new Error('Binary file'); + } catch { + throw new GitLabOperationError( + 'File read failed: the file may be unavailable, exceed the 1 MiB limit, or not be valid UTF-8 text. No file content was returned.', + ); + } + const lines = content.match(/[^\n]*\n|[^\n]+$/g) ?? []; + const offset = fileInput.offset ?? 0; + const selected = lines.slice(offset, offset + (fileInput.limit ?? 2000)); + const nextOffset = Math.min(offset + selected.length, lines.length); + payload = { + project_id: projectId, + file_path: fileInput.file_path, + ref: fileInput.ref, + size_bytes: Buffer.byteLength(content), + total_lines: lines.length, + offset, + lines_returned: selected.length, + next_offset: nextOffset < lines.length ? nextOffset : null, + truncated: offset > 0 || nextOffset < lines.length, + content: selected.join(''), + }; + } else if (name === 'get_merge_request') { + payload = await getGitLabMergeRequest(mrOptions); + } else if (name === 'create_merge_request_note') { + payload = await createGitLabMergeRequestNote({ + ...mrOptions, + body: String(args.body), + }); + } else { + let path: string; + let method: 'GET' | 'POST' | 'PUT' = 'GET'; + let body: Record | undefined; + const params: Record = {}; + const paged = [ + 'get_repository_tree', + 'search_project_code', + 'list_commits', + 'list_merge_request_diffs', + 'get_merge_request_notes', + 'mr_discussions', + ].includes(name); + if (paged) { + params.per_page = Number(args.per_page ?? 20); + if (name !== 'get_repository_tree') + params.page = Number(args.page ?? 1); + } + switch (name) { + case 'get_repository_tree': + path = `${root}/repository/tree`; + params.pagination = 'keyset'; + break; + case 'search_project_code': + path = `${root}/search`; + params.scope = 'blobs'; + break; + case 'list_commits': + path = `${root}/repository/commits`; + break; + case 'get_commit': + path = `${root}/repository/commits/${encodeURIComponent(String(args.sha))}`; + break; + case 'list_merge_request_diffs': + path = `${mrPath}/diffs`; + break; + case 'get_merge_request_notes': + path = `${mrPath}/notes`; + break; + case 'mr_discussions': + path = `${mrPath}/discussions`; + break; + case 'update_merge_request': + path = mrPath; + method = 'PUT'; + body = Object.fromEntries( + ['title', 'description', 'state_event'] + .filter((key) => args[key] !== undefined) + .map((key) => [key, args[key]]), + ); + break; + case 'create_merge_request_discussion_note': + path = `${mrPath}/discussions/${args.discussion_id}/notes`; + method = 'POST'; + body = { body: args.body }; + break; + default: + throw new Error('Unsupported tool'); + } + for (const key of [ + 'path', + 'ref', + 'recursive', + 'page_token', + 'search', + 'ref_name', + ]) { + const value = args[key]; + if (typeof value === 'string' || typeof value === 'boolean') { + params[key] = value; + } + } + let response: Response; + try { + response = await requestGitLab( + { ...options, path, params, method, body }, + [200, 201], + ); + } catch (error) { + if ( + name === 'search_project_code' && + error instanceof GitLabApiError && + [400, 403, 404, 405, 501].includes(error.status) + ) { + throw new GitLabOperationError( + 'Project code search is unavailable on this GitLab instance or for this connection. No unscoped search was attempted.', + ); + } + throw error; + } + const data: unknown = await response.json(); + if (paged) { + if (!Array.isArray(data) || data.length > Number(params.per_page)) { + throw new Error('Invalid page'); + } + if (name === 'get_repository_tree') { + // Extract only the cursor. Never fetch or expose provider-supplied URLs. + const link = response.headers + .get('link') + ?.split(',') + .find((part) => /;\s*rel="next"/.test(part)); + const next = link?.match(/<([^>]+)>/)?.[1]; + const cursor = next + ? new URL(next, options.apiBaseUrl).searchParams.get('page_token') + : null; + payload = { + items: data, + next_page_token: cursor + ? gitLabPageTokenSchema.parse(cursor) + : null, + }; + } else { + const next = response.headers.get('x-next-page'); + payload = { + items: data, + next_page: next + ? Number( + gitLabToolSchemas.get_merge_request.shape.merge_request_iid.parse( + next, + ), + ) + : null, + }; + } + } else { + payload = data; + } + } + + return safeToolResult(payload, [ + token, + connection.accessToken, + connection.refreshToken, + connection.clientSecret, + refreshedConnection.accessToken, + refreshedConnection.refreshToken, + refreshedConnection.clientSecret, + ]); + } finally { + controller.abort(); + clearTimeout(timer); + } +} diff --git a/apps/api/src/handlers/mcp/gitlab/schemas.ts b/apps/api/src/handlers/mcp/gitlab/schemas.ts new file mode 100644 index 000000000..531c99896 --- /dev/null +++ b/apps/api/src/handlers/mcp/gitlab/schemas.ts @@ -0,0 +1,120 @@ +import { z } from 'zod/v4'; + +const id = z + .string() + .regex(/^[1-9][0-9]*$/) + .refine((value) => Number.isSafeInteger(Number(value))); + +const project = z.union([ + id, + z.number().int().positive(), + z.string().regex(/^[\w.-]+(?:\/[\w.-]+)+$/), +]); + +const text = z.string().min(1); +const path = text.refine( + (value) => + !/[\\\x00-\x1f\x7f]/.test(value) && + value + .split('/') + .every((part) => part !== '' && part !== '.' && part !== '..'), +); +const pagination = { + page: z.number().int().min(1).optional(), + per_page: z.number().int().min(1).max(100).optional(), +}; +export const gitLabPageTokenSchema = z + .string() + .min(1) + .regex(/^[A-Za-z0-9_+/=-]+$/); +const mr = { project_id: project, merge_request_iid: id }; + +export const gitLabToolSchemas = { + get_file_contents: z.strictObject({ + project_id: project, + file_path: path, + ref: z + .string() + .regex(/^[a-fA-F0-9]{40}$/) + .describe( + 'Full immutable commit SHA. Resolve a branch with get_commit first.', + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Zero-based line offset; default 0.'), + limit: z + .number() + .int() + .min(1) + .max(2000) + .optional() + .describe( + 'Maximum lines; default 2000. Files over 1 MiB are rejected even for a small window.', + ), + }), + get_repository_tree: z.strictObject({ + project_id: project, + path: path.optional(), + ref: text.optional(), + recursive: z.boolean().optional(), + per_page: pagination.per_page, + page_token: gitLabPageTokenSchema.optional(), + pagination: z + .literal('keyset') + .optional() + .describe( + 'Keyset pagination; pass next_page_token as page_token to continue.', + ), + }), + search_project_code: z.strictObject({ + project_id: project, + search: text, + ref: text.optional(), + ...pagination, + }), + list_commits: z.strictObject({ + project_id: project, + ref_name: text.optional(), + path: path.optional(), + ...pagination, + }), + get_commit: z.strictObject({ project_id: project, sha: path }), + get_merge_request: z.strictObject(mr), + list_merge_request_diffs: z.strictObject({ ...mr, ...pagination }), + get_merge_request_notes: z.strictObject({ ...mr, ...pagination }), + mr_discussions: z.strictObject({ ...mr, ...pagination }), + update_merge_request: z + .strictObject({ + ...mr, + title: text.optional(), + description: z.string().optional(), + state_event: z.enum(['close', 'reopen']).optional(), + }) + .refine( + (input) => + input.title !== undefined || + input.description !== undefined || + input.state_event !== undefined, + { message: 'At least one merge request update is required.' }, + ), + create_merge_request_note: z.strictObject({ ...mr, body: text }), + create_merge_request_discussion_note: z.strictObject({ + ...mr, + discussion_id: z.string().regex(/^[a-zA-Z0-9_-]+$/), + body: text, + }), +}; + +export type GitLabToolName = keyof typeof gitLabToolSchemas; +export type GitLabToolInput = z.infer< + (typeof gitLabToolSchemas)[GitLabToolName] +>; + +export const gitLabWriteTools = new Set([ + 'update_merge_request', + 'create_merge_request_note', + 'create_merge_request_discussion_note', +]); diff --git a/apps/api/src/handlers/mcp/gitlab/tools.ts b/apps/api/src/handlers/mcp/gitlab/tools.ts new file mode 100644 index 000000000..afba57210 --- /dev/null +++ b/apps/api/src/handlers/mcp/gitlab/tools.ts @@ -0,0 +1,61 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { + executeGitLabTool, + GitLabOperationError, + type GitLabMcpContext, +} from './operations'; +import { + gitLabToolSchemas, + gitLabWriteTools, + type GitLabToolInput, + type GitLabToolName, +} from './schemas'; + +function description(name: GitLabToolName) { + if (name === 'get_file_contents') { + return 'Read a UTF-8 file at an immutable commit, at most 1 MiB and 2000 lines, with continuation metadata.'; + } + if (name === 'search_project_code') { + return 'Search code in this connected project only. Requires instance support for blob search; no unscoped fallback.'; + } + return `GitLab ${name.replaceAll('_', ' ')} in an active connected repository.`; +} + +export function registerGitLabTools( + server: McpServer, + context: GitLabMcpContext, +) { + for (const name of Object.keys(gitLabToolSchemas) as GitLabToolName[]) { + server.registerTool( + name, + { + description: description(name), + inputSchema: gitLabToolSchemas[name], + annotations: { + readOnlyHint: !gitLabWriteTools.has(name), + destructiveHint: gitLabWriteTools.has(name), + openWorldHint: true, + }, + }, + async (input: GitLabToolInput) => { + try { + return await executeGitLabTool(context, name, input); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: + error instanceof GitLabOperationError + ? error.message + : 'GitLab operation unavailable, unsupported, or outside the permitted scope. No successful result was received.', + }, + ], + }; + } + }, + ); + } +}