UNPKG

sourcegraph-mcp-server

Version:

A Model Context Protocol (MCP) server that connects AI assistants to Sourcegraph code search with natural language capabilities

651 lines (650 loc) 26.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.setupHttpServer = void 0; const express_1 = __importDefault(require("express")); const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js"); const zod_1 = require("zod"); const axios_1 = __importDefault(require("axios")); const dotenv_1 = __importDefault(require("dotenv")); // Load environment variables dotenv_1.default.config(); // Sourcegraph API configuration const sgUrl = process.env.SOURCEGRAPH_URL; const sgToken = process.env.SOURCEGRAPH_TOKEN; // Log configuration for debugging (redact token for security) console.log(`MCP Server - Sourcegraph URL: ${sgUrl || 'NOT SET'}`); console.log(`MCP Server - Sourcegraph Token: ${sgToken ? 'SET (redacted)' : 'NOT SET'}`); // Create an MCP server - properly following the MCP SDK const server = new mcp_js_1.McpServer({ name: "Sourcegraph MCP Server", version: "1.0.0", protocolVersion: "1.0" // Explicitly set protocol version }); // Add a greeting resource server.resource("greeting", "greeting://hello", async (uri) => ({ contents: [{ uri: uri.href, text: `Hello from Sourcegraph MCP Server! Ready to search code repositories.` }] })); // Add code search tool server.tool("search-code", { query: zod_1.z.string().describe("Search query text"), type: zod_1.z.enum(['file', 'commit', 'diff']).default('file').describe("Type of search: file, commit, or diff") }, async ({ query, type }) => { // Validate Sourcegraph credentials const effectiveUrl = sgUrl || process.env.SOURCEGRAPH_URL; const effectiveToken = sgToken || process.env.SOURCEGRAPH_TOKEN; if (!effectiveUrl || !effectiveToken) { return { content: [{ type: "text", text: "Error: Sourcegraph URL or token not configured. Please set SOURCEGRAPH_URL and SOURCEGRAPH_TOKEN environment variables." }], isError: true }; } try { // Build the search query const searchQuery = `${query} type:${type} count:20`; // The GraphQL query const graphqlQuery = ` query CodeSearch($query: String!) { search(query: $query, version: V3) { results { matchCount results { __typename ... on FileMatch { repository { name } file { path } lineMatches { lineNumber preview } } ... on CommitSearchResult { commit { oid message author { person { name email } date } repository { name } } } } } } } `; // Headers for Sourcegraph API const headers = { 'Authorization': `token ${effectiveToken}`, 'Content-Type': 'application/json' }; // Make the request to Sourcegraph API const response = await axios_1.default.post(`${effectiveUrl}/.api/graphql`, { query: graphqlQuery, variables: { query: searchQuery } }, { headers }); if (response.data.errors) { return { content: [{ type: "text", text: `Sourcegraph API Error: ${JSON.stringify(response.data.errors)}` }], isError: true }; } // Format the results const results = response.data.data.search.results; const matchCount = results.matchCount; const items = results.results; // Create a formatted result const formattedItems = items.map((item) => { if (item.__typename === 'FileMatch') { const repo = item.repository.name; const filePath = item.file.path; const matches = item.lineMatches.map((match) => `Line ${match.lineNumber}: ${match.preview}`).join('\n'); return `Repository: ${repo}\nFile: ${filePath}\n${matches}\n`; } else if (item.__typename === 'CommitSearchResult') { const commit = item.commit; const repo = commit.repository.name; return `Repository: ${repo}\nCommit: ${commit.oid.substring(0, 7)}\nAuthor: ${commit.author.person.name}\nDate: ${commit.author.date}\nMessage: ${commit.message}`; } return null; }).filter(Boolean).join('\n---\n'); return { content: [{ type: "text", text: `Found ${matchCount} matches for "${query}" with type:${type}\n\n${formattedItems}` }] }; } catch (error) { return { content: [{ type: "text", text: `Error searching Sourcegraph: ${error.message || 'Unknown error'}` }], isError: true }; } }); // Add commit search tool server.tool("search-commits", { author: zod_1.z.string().optional().describe("Filter by commit author"), message: zod_1.z.string().optional().describe("Filter by commit message"), after: zod_1.z.string().optional().describe("Filter for commits after this date (YYYY-MM-DD)") }, async ({ author, message, after }) => { // Validate Sourcegraph credentials const effectiveUrl = sgUrl || process.env.SOURCEGRAPH_URL; const effectiveToken = sgToken || process.env.SOURCEGRAPH_TOKEN; if (!effectiveUrl || !effectiveToken) { return { content: [{ type: "text", text: "Error: Sourcegraph URL or token not configured. Please set SOURCEGRAPH_URL and SOURCEGRAPH_TOKEN environment variables." }], isError: true }; } try { // Build the search query let searchQuery = 'type:commit'; if (author) searchQuery += ` author:${author}`; if (message) searchQuery += ` message:${message}`; if (after) searchQuery += ` after:${after}`; searchQuery += ' count:20'; // The GraphQL query const graphqlQuery = ` query CommitSearch($query: String!) { search(query: $query, version: V3) { results { matchCount results { __typename ... on CommitSearchResult { commit { oid message author { person { name email } date } repository { name } } } } } } } `; // Headers for Sourcegraph API const headers = { 'Authorization': `token ${effectiveToken}`, 'Content-Type': 'application/json' }; // Make the request to Sourcegraph API const response = await axios_1.default.post(`${effectiveUrl}/.api/graphql`, { query: graphqlQuery, variables: { query: searchQuery } }, { headers }); if (response.data.errors) { return { content: [{ type: "text", text: `Sourcegraph API Error: ${JSON.stringify(response.data.errors)}` }], isError: true }; } // Format the results const results = response.data.data.search.results; const matchCount = results.matchCount; const items = results.results; // Create a formatted result const formattedItems = items .filter((item) => item.__typename === 'CommitSearchResult') .map((item) => { const commit = item.commit; const repo = commit.repository.name; return `Repository: ${repo}\nCommit: ${commit.oid.substring(0, 7)}\nAuthor: ${commit.author.person.name} <${commit.author.person.email}>\nDate: ${commit.author.date}\nMessage: ${commit.message}`; }) .join('\n---\n'); return { content: [{ type: "text", text: `Found ${matchCount} commits matching the criteria\n\n${formattedItems}` }] }; } catch (error) { return { content: [{ type: "text", text: `Error searching Sourcegraph commits: ${error.message || 'Unknown error'}` }], isError: true }; } }); // Add diff search tool server.tool("search-diffs", { query: zod_1.z.string().optional().describe("Search query text"), author: zod_1.z.string().optional().describe("Filter by commit author"), after: zod_1.z.string().optional().describe("Filter for diffs after this date (YYYY-MM-DD)") }, async ({ query, author, after }) => { // Validate Sourcegraph credentials const effectiveUrl = sgUrl || process.env.SOURCEGRAPH_URL; const effectiveToken = sgToken || process.env.SOURCEGRAPH_TOKEN; if (!effectiveUrl || !effectiveToken) { return { content: [{ type: "text", text: "Error: Sourcegraph URL or token not configured. Please set SOURCEGRAPH_URL and SOURCEGRAPH_TOKEN environment variables." }], isError: true }; } try { // Build the search query let searchQuery = 'type:diff'; if (query) searchQuery += ` ${query}`; if (author) searchQuery += ` author:${author}`; if (after) searchQuery += ` after:${after}`; searchQuery += ' count:20'; // The GraphQL query const graphqlQuery = ` query DiffSearch($query: String!) { search(query: $query, version: V3) { results { matchCount results { __typename ... on CommitSearchResult { commit { oid message author { person { name email } date } repository { name } } diff { fileDiffs { oldPath newPath hunks { body oldRange { start, lines } newRange { start, lines } } } } } } } } } `; // Headers for Sourcegraph API const headers = { 'Authorization': `token ${effectiveToken}`, 'Content-Type': 'application/json' }; // Make the request to Sourcegraph API const response = await axios_1.default.post(`${effectiveUrl}/.api/graphql`, { query: graphqlQuery, variables: { query: searchQuery } }, { headers }); if (response.data.errors) { return { content: [{ type: "text", text: `Sourcegraph API Error: ${JSON.stringify(response.data.errors)}` }], isError: true }; } // Format the results const results = response.data.data.search.results; const matchCount = results.matchCount; const items = results.results; // Create a formatted result const formattedItems = items .filter((item) => item.__typename === 'CommitSearchResult' && item.diff) .map((item) => { const commit = item.commit; const repo = commit.repository.name; const commitInfo = `Commit: ${commit.oid.substring(0, 7)}\nAuthor: ${commit.author.person.name}\nDate: ${commit.author.date}\nMessage: ${commit.message}\n`; let diffInfo = ''; if (item.diff && item.diff.fileDiffs) { diffInfo = item.diff.fileDiffs.map((fileDiff) => { const pathInfo = `${fileDiff.oldPath || ''} → ${fileDiff.newPath || ''}`; const hunksInfo = fileDiff.hunks ? fileDiff.hunks.map((hunk) => { return `@@ -${hunk.oldRange.start},${hunk.oldRange.lines} +${hunk.newRange.start},${hunk.newRange.lines} @@\n${hunk.body}`; }).join('\n') : 'No diff hunks available'; return `File: ${pathInfo}\n${hunksInfo}`; }).join('\n\n'); } return `Repository: ${repo}\n${commitInfo}\nDiff:\n${diffInfo}`; }) .join('\n---\n'); return { content: [{ type: "text", text: `Found ${matchCount} diffs matching the criteria\n\n${formattedItems}` }] }; } catch (error) { return { content: [{ type: "text", text: `Error searching Sourcegraph diffs: ${error.message || 'Unknown error'}` }], isError: true }; } }); // Server implementation for HTTP/SSE const setupHttpServer = () => { const app = (0, express_1.default)(); const port = 3002; // Use a fixed port for the MCP server // Configure CORS app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.sendStatus(200); } else { next(); } }); // Session management for multiple connections const transports = {}; // SSE endpoint for client to receive messages app.get("/sse", (req, res) => { console.log("New MCP SSE connection established"); // Set required headers for SSE res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); // Create a unique session ID const sessionId = Math.random().toString(36).substring(2, 15); console.log(`Created session ID: ${sessionId}`); // Send initial heartbeat res.write(`data: {"type":"connected","sessionId":"${sessionId}"}\n\n`); // Create transport after headers are sent const transport = new sse_js_1.SSEServerTransport('/messages', res); transports[transport.sessionId] = transport; // Setup cleanup on connection close res.on("close", () => { console.log(`MCP SSE connection closed for session ${transport.sessionId}`); delete transports[transport.sessionId]; }); // Connect to MCP server after response has started setTimeout(async () => { try { await server.connect(transport); console.log(`Successfully connected transport for session ${transport.sessionId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error('Error connecting MCP server to transport:', errorMessage); // Only write if response is still writable if (!res.writableEnded) { res.write(`data: {"type":"error","message":"${errorMessage}"}\n\n`); } } }, 100); // Small delay to ensure headers are sent first }); // Message endpoint for client to send messages app.post("/messages", express_1.default.json(), async (req, res) => { // Check for session ID in query params or headers const sessionId = (req.query.sessionId || req.headers['x-mcp-session-id']); console.log(`Received message for sessionId: ${sessionId}`); console.log(`Available transports: ${Object.keys(transports).join(', ')}`); const transport = transports[sessionId]; if (transport) { try { console.log(`Processing message for session ${sessionId}`); await transport.handlePostMessage(req, res); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error handling POST message for session ${sessionId}:`, errorMessage); res.status(500).json({ error: `Internal server error: ${errorMessage}` }); } } else { console.warn(`No transport found for sessionId: ${sessionId}`); // More helpful error message with available session IDs res.status(400).json({ error: 'No transport found for sessionId', sessionId, availableSessions: Object.keys(transports) }); } }); // Health check endpoint app.get("/", (req, res) => { res.json({ name: "Sourcegraph MCP Server", version: "1.0.0", status: "running", tools: [ "search-code", "search-commits", "search-diffs" ] }); }); // Add an MCP discovery endpoint app.get("/mcp", (req, res) => { // Check if this is an SSE connection request const transportType = req.query.transportType; if (transportType === 'sse') { console.log("MCP SSE connection requested via /mcp endpoint"); // Redirect to SSE endpoint by setting up SSE response here res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); // Create a unique session ID const sessionId = Math.random().toString(36).substring(2, 15); console.log(`Created session ID via /mcp: ${sessionId}`); // Send initial heartbeat res.write(`data: {"type":"connected","sessionId":"${sessionId}"}\n\n`); // Create transport after headers are sent const transport = new sse_js_1.SSEServerTransport('/messages', res); transports[transport.sessionId] = transport; // Setup cleanup on connection close res.on("close", () => { console.log(`MCP SSE connection closed for session ${transport.sessionId}`); delete transports[transport.sessionId]; }); // Connect to MCP server after response has started setTimeout(async () => { try { await server.connect(transport); console.log(`Successfully connected transport for session ${transport.sessionId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error('Error connecting MCP server to transport:', errorMessage); // Only write if response is still writable if (!res.writableEnded) { res.write(`data: {"type":"error","message":"${errorMessage}"}\n\n`); } } }, 100); } else { // Regular JSON response for API discovery res.json({ name: "Sourcegraph MCP Server", description: "MCP server for accessing Sourcegraph search capabilities", version: "1.0.0", protocolVersion: "1.0", transport: "sse", sse: { url: "/sse", messageEndpoint: "/messages" } }); } }); // Adjusted to match OpenAPI format expected by MCP Inspector app.get("/resources", (req, res) => { res.json({ openapi: "3.0.0", info: { title: "Sourcegraph Search Tools", version: "1.0.0", description: "MCP tools for searching Sourcegraph repositories" }, paths: {}, components: { schemas: {} }, functions: [ { name: "search-code", description: "Search for code across Sourcegraph repositories", parameters: { type: "object", properties: { query: { type: "string", description: "The search query string" }, type: { type: "string", enum: ["file", "commit", "diff"], default: "file", description: "Optional search type (file, commit, diff). Default: file" } }, required: ["query"] }, returns: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string" }, text: { type: "string" } } } } } } }, { name: "search-commits", description: "Search for commits in Sourcegraph repositories", parameters: { type: "object", properties: { author: { type: "string", description: "Optional: filter by commit author" }, message: { type: "string", description: "Optional: filter by commit message" }, after: { type: "string", description: "Optional: filter for commits after a specific date (YYYY-MM-DD)" } } }, returns: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string" }, text: { type: "string" } } } } } } }, { name: "search-diffs", description: "Search for code changes (diffs) in Sourcegraph repositories", parameters: { type: "object", properties: { query: { type: "string", description: "Optional: the search query string" }, author: { type: "string", description: "Optional: filter by commit author" }, after: { type: "string", description: "Optional: filter for diffs after a specific date (YYYY-MM-DD)" } } }, returns: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string" }, text: { type: "string" } } } } } } } ] }); }); // Start the server app.listen(port, () => { console.log(`Sourcegraph MCP Server running at http://localhost:${port}`); console.log(`Connect MCP clients to: http://localhost:${port}/sse`); }); }; exports.setupHttpServer = setupHttpServer;