GitHub Support Assistant

  • src
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const GITHUB_API_BASE = "https://api.github.com"; const USER_AGENT = "support-assistant/1.0"; // GitHub Personal Access Token should be set in environment variables const GITHUB_TOKEN = process.env.GITHUB_TOKEN; // Create server instance const server = new McpServer({ name: "support-assistant", version: "1.0.0", }); // Helper function for making GitHub API requests async function makeGithubRequest<T>(path: string, params: Record<string, string> = {}): Promise<T | null> { const url = new URL(path, GITHUB_API_BASE); // Add query parameters Object.entries(params).forEach(([key, value]) => { if (value) url.searchParams.append(key, value); }); const headers: { "User-Agent": string; "Accept": string; "X-GitHub-Api-Version": string; Authorization?: string; } = { "User-Agent": USER_AGENT, "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }; // Add authorization if token is available if (GITHUB_TOKEN) { headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`; } try { const response = await fetch(url.toString(), { headers }); if (!response.ok) { throw new Error(`GitHub API error! status: ${response.status}`); } return (await response.json()) as T; } catch (error) { console.error("Error making GitHub request:", error); return null; } } interface GithubIssue { html_url: string; number: number; title: string; state: string; body: string; created_at: string; updated_at: string; closed_at: string | null; labels: Array<{ name: string; }>; } interface GithubSearchResponse { total_count: number; incomplete_results: boolean; items: GithubIssue[]; } // Calculate similarity score between two texts (simple implementation) function calculateSimilarity(text1: string, text2: string): number { // Convert to lowercase and remove special characters for comparison const normalize = (text: string) => text.toLowerCase().replace(/[^\w\s]/g, ''); const words1 = new Set(normalize(text1).split(/\s+/)); const words2 = new Set(normalize(text2).split(/\s+/)); // Find intersection of words const intersection = new Set([...words1].filter(word => words2.has(word))); // Calculate Jaccard similarity coefficient const union = new Set([...words1, ...words2]); return intersection.size / union.size; } // Format issue data for display function formatIssue(issue: GithubIssue, similarityScore: number): string { const labels = issue.labels.map(label => label.name).join(", "); const status = issue.state === "closed" ? "Closed" : "Open"; const closedDate = issue.closed_at ? new Date(issue.closed_at).toLocaleDateString() : "N/A"; return [ `Issue #${issue.number}: ${issue.title}`, `URL: ${issue.html_url}`, `Status: ${status}${issue.closed_at ? ` (closed on ${closedDate})` : ''}`, `Labels: ${labels || "None"}`, `Similarity: ${(similarityScore * 100).toFixed(1)}%`, "---", ].join("\n"); } // Register support tools server.tool( "find-similar-issues", "Find GitHub issues similar to a new issue description", { owner: z.string().describe("GitHub repository owner/organization"), repo: z.string().describe("GitHub repository name"), issueDescription: z.string().describe("Description of the issue to find similar ones for"), maxResults: z.number().int().min(1).max(20).default(5).describe("Maximum number of similar issues to return") }, async ({ owner, repo, issueDescription, maxResults }) => { // Combine title and description for better search results const searchText = `${issueDescription}`; // Extract important keywords for search (simple approach) const keywords = searchText .toLowerCase() .replace(/[^\w\s]/g, '') .split(/\s+/) .filter(word => word.length > 3) // Filter out short words .filter(word => !['the', 'and', 'that', 'this', 'with'].includes(word)) // Filter common words .slice(0, 10) // Limit number of keywords .join(' '); // Search for issues in the repository const searchParams = { q: `repo:${owner}/${repo} ${keywords}`, sort: 'updated', order: 'desc', per_page: '30' // Get more results to filter by similarity }; const searchResponse = await makeGithubRequest<GithubSearchResponse>( '/search/issues', searchParams ); if (!searchResponse) { return { content: [ { type: "text", text: "Failed to retrieve issues from GitHub" } ] }; } if (searchResponse.total_count === 0) { return { content: [ { type: "text", text: `No similar issues found in ${owner}/${repo}` } ] }; } // Calculate similarity score for each issue const issuesWithScores = searchResponse.items .map(issue => ({ issue, score: calculateSimilarity(searchText, `${issue.title} ${issue.body || ''}`) })) .sort((a, b) => b.score - a.score) // Sort by similarity score (highest first) .slice(0, maxResults); // Take top N results // Format the response const formattedIssues = issuesWithScores.map(({ issue, score }) => formatIssue(issue, score) ); const responseText = `Found ${issuesWithScores.length} similar issues in ${owner}/${repo}:\n\n${formattedIssues.join("\n")}`; return { content: [ { type: "text", text: responseText } ] }; } ); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Support Assistant MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });