From c9abaed91fd25b07f42fbc9b0a09ad2198a3cc17 Mon Sep 17 00:00:00 2001 From: u60196 Date: Fri, 10 Oct 2025 15:09:20 +0200 Subject: [PATCH] Initialize project with GitMCP router implementation and TypeScript configuration --- dist/index.js | 222 +++++++++++++++++++++++++++++++++++++++++++ package.json | 20 ++++ src/index.ts | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 15 +++ 4 files changed, 515 insertions(+) create mode 100644 dist/index.js create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..aa3e28e --- /dev/null +++ b/dist/index.js @@ -0,0 +1,222 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +function normalizeToGitMcp(urlOrSlug) { + if (!urlOrSlug || typeof urlOrSlug !== 'string') { + throw new Error(`Invalid input: Expected a string URL or repo slug, got: ${typeof urlOrSlug}`); + } + const raw = urlOrSlug.trim(); + if (!raw) { + throw new Error('Empty URL or repo slug provided'); + } + // If it's already a full GitHub URL, just replace github.com with gitmcp.io + if (raw.startsWith("http") || raw.startsWith("github.com")) { + const url = raw.startsWith("http") ? raw : "https://" + raw; + return url.replace(/github\.com/g, "gitmcp.io"); + } + // Handle owner/repo format with optional branch + const [lhs, maybeBranch] = raw.split("#"); + const segs = lhs.split("/").filter(Boolean); + if (segs.length !== 2) { + throw new Error(`Could not parse repo from "${urlOrSlug}". Expected "owner/repo" or GitHub URL.`); + } + const [owner, repo] = segs; + const base = `https://gitmcp.io/${owner}/${repo}`; + return maybeBranch ? `${base}/tree/${maybeBranch}` : base; +} +const tools = [ + { + name: "fetch_generic_url_content", + description: "Generic tool to fetch content from any absolute URL, respecting robots.txt rules. Use this to retrieve referenced urls (absolute urls) that were mentioned in previously fetched documentation.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + }, + url: { + type: "string", + description: "The URL of the document or page to fetch" + } + }, + required: ["url"] + } + }, + { + name: "fetch_documentation", + description: "Fetch entire documentation file from the connected GitHub repository. Useful for general questions. Always call this tool first if asked about the repository. A usefull file to fetch is readme.md. Requires 'path' parameter with GitHub URL if not already connected.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + } + }, + required: [] + } + }, + { + name: "search_code", + description: "Search for code within the connected GitHub repository using the GitHub Search API (exact match). Returns matching files for you to query further if relevant. Requires 'path' parameter with GitHub URL if not already connected.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + }, + query: { + type: "string", + description: "The search query to find relevant code files" + }, + page: { + type: "number", + description: "Page number to retrieve (starting from 1). Each page contains 30 results." + } + }, + required: ["query"] + } + }, + { + name: "search_documentation", + description: "Semantically search within the fetched documentation from the connected GitHub repository. Useful for specific queries. Requires 'path' parameter with GitHub URL if not already connected.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + }, + query: { + type: "string", + description: "The search query to find relevant documentation" + } + }, + required: ["query"] + } + } +]; +class GitMcpRouter { + server; + downstream = null; + currentRepo = null; + constructor() { + this.server = new Server({ name: "gitmcp-router", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } }); + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + // Always expose these common GitMCP tools plus our connector + return { + tools: tools + }; + }); + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const { path } = request.params.arguments; + // If we have a path parameter, ensure we're connected to that repository + if (path && (!this.downstream || this.currentRepo !== path)) { + console.log(`Connecting to new repository: ${path}`); + await this.loadRepo(path).catch((e) => { + throw new Error(`Failed to connect to repository: ${e instanceof Error ? e.message : String(e)}`); + }); + } + // If no path and no connection, provide helpful error + if (!this.downstream && !path) { + throw new Error(`No repository connected. Please provide a 'path' parameter with a GitHub URL (e.g., "https://github.com/modelcontextprotocol/servers") to connect to a repository first.`); + } + // const toolNames = [ + // 'fetch_documentation', + // 'search_documentation', + // 'search_code', + // 'fetch_generic_url_content' + // ] + const toolNames = tools.map(t => t.name); + // const downstreamToolNames = [ + // 'fetch_query_documentation', + // 'search_query_documentation', + // 'search_query_code', + // 'fetch_generic_url_content' + // ] + const downstreamToolNames = await this.downstream?.listTools().then((tools) => { + return tools.tools.map(t => t.name); + }); + // Map tool names to the actual GitMCP tool names and forward the call + // const toolMapping: Record = { + // "fetch_documentation": "fetch_query_documentation", + // "search_code": "search_query_code", + // "search_documentation": "search_query_documentation", + // "fetch_generic_url_content": "fetch_generic_url_content" + // }; + const toolMapping = toolNames.reduce((acc, routerToolName) => { + // Find matching downstream tool by checking if downstream tool contains all parts of router tool + const matchingDownstreamTool = downstreamToolNames?.find(downstreamTool => routerToolName.split("_").every(part => downstreamTool.includes(part))); + acc[routerToolName] = matchingDownstreamTool || routerToolName; + return acc; + }, {}); + const actualToolName = toolMapping[request.params.name]; + if (!actualToolName) { + throw new Error(`Unknown tool: ${request.params.name}`); + } + console.log("Tool mapping:", toolMapping); + // Remove path from arguments when forwarding to downstream + const { path: _, ...forwardArgs } = request.params.arguments; + return await this.downstream.callTool({ + name: actualToolName, + arguments: forwardArgs + }); + } + catch (error) { + return { + content: [{ + type: "text", + text: `Error: ${error instanceof Error ? error.message : String(error)}` + }] + }; + } + }); + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + if (!this.downstream) + throw new Error("No repository loaded"); + return await this.downstream.listResources(); + }); + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + if (!this.downstream) + throw new Error("No repository loaded"); + return await this.downstream.readResource(request.params); + }); + } + async start() { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } + async loadRepo(githubUrl) { + // Convert GitHub URL directly to GitMCP URL + const gitMcpUrl = normalizeToGitMcp(githubUrl); + if (this.downstream) { + try { + await this.downstream.close(); + } + catch { } + } + const transport = new SSEClientTransport(new URL(gitMcpUrl)); + this.downstream = new Client({ name: "gitmcp-router-client", version: "0.1.0" }); + await this.downstream.connect(transport); + this.currentRepo = githubUrl; + console.error(`Connected to GitMCP: ${githubUrl} -> ${gitMcpUrl}`); + // Debug: List what tools are actually available from downstream + try { + const downstreamTools = await this.downstream.listTools(); + console.error(`Available downstream tools: ${JSON.stringify(downstreamTools.tools.map(t => t.name))}`); + } + catch (e) { + console.error(`Error listing downstream tools: ${e}`); + } + } +} +const router = new GitMcpRouter(); +router.start().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba1a353 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "g-source", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.16.0", + "eventsource": "^2.0.2", + "undici": "^6.19.8", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.7.1", + "typescript": "^5.6.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0dae781 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,258 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + Tool +} from "@modelcontextprotocol/sdk/types.js"; + +function normalizeToGitMcp(urlOrSlug: string): string { + if (!urlOrSlug || typeof urlOrSlug !== 'string') { + throw new Error(`Invalid input: Expected a string URL or repo slug, got: ${typeof urlOrSlug}`); + } + + const raw = urlOrSlug.trim(); + + if (!raw) { + throw new Error('Empty URL or repo slug provided'); + } + + // If it's already a full GitHub URL, just replace github.com with gitmcp.io + if (raw.startsWith("http") || raw.startsWith("github.com")) { + const url = raw.startsWith("http") ? raw : "https://" + raw; + return url.replace(/github\.com/g, "gitmcp.io"); + } + + // Handle owner/repo format with optional branch + const [lhs, maybeBranch] = raw.split("#"); + const segs = lhs.split("/").filter(Boolean); + + if (segs.length !== 2) { + throw new Error(`Could not parse repo from "${urlOrSlug}". Expected "owner/repo" or GitHub URL.`); + } + + const [owner, repo] = segs; + const base = `https://gitmcp.io/${owner}/${repo}`; + return maybeBranch ? `${base}/tree/${maybeBranch}` : base; +} + +const tools: Tool[] = [ + { + name: "fetch_generic_url_content", + description: "Generic tool to fetch content from any absolute URL, respecting robots.txt rules. Use this to retrieve referenced urls (absolute urls) that were mentioned in previously fetched documentation.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + }, + url: { + type: "string", + description: "The URL of the document or page to fetch" + } + }, + required: ["url"] + } + }, + { + name: "fetch_documentation", + description: "Fetch entire documentation file from the connected GitHub repository. Useful for general questions. Always call this tool first if asked about the repository. A usefull file to fetch is readme.md. Requires 'path' parameter with GitHub URL if not already connected.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + } + }, + required: [] + } + }, + { + name: "search_code", + description: "Search for code within the connected GitHub repository using the GitHub Search API (exact match). Returns matching files for you to query further if relevant. Requires 'path' parameter with GitHub URL if not already connected.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + }, + query: { + type: "string", + description: "The search query to find relevant code files" + }, + page: { + type: "number", + description: "Page number to retrieve (starting from 1). Each page contains 30 results." + } + }, + required: ["query"] + } + }, + { + name: "search_documentation", + description: "Semantically search within the fetched documentation from the connected GitHub repository. Useful for specific queries. Requires 'path' parameter with GitHub URL if not already connected.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "GitHub URL (e.g., https://github.com/owner/repo/tree/branch) - required for first connection" + }, + query: { + type: "string", + description: "The search query to find relevant documentation" + } + }, + required: ["query"] + } + } +] + +class GitMcpRouter { + private server: Server; + private downstream: Client | null = null; + private currentRepo: string | null = null; + + constructor() { + this.server = new Server( + { name: "gitmcp-router", version: "0.1.0" }, + { capabilities: { tools: {}, resources: {} } } + ); + + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + // Always expose these common GitMCP tools plus our connector + return { + tools: tools + }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const { path } = request.params.arguments as { path?: string }; + + // If we have a path parameter, ensure we're connected to that repository + if (path && (!this.downstream || this.currentRepo !== path)) { + console.log(`Connecting to new repository: ${path}`); + + await this.loadRepo(path).catch((e) => { + throw new Error(`Failed to connect to repository: ${e instanceof Error ? e.message : String(e)}`); + }); + } + + // If no path and no connection, provide helpful error + if (!this.downstream && !path) { + throw new Error(`No repository connected. Please provide a 'path' parameter with a GitHub URL (e.g., "https://github.com/modelcontextprotocol/servers") to connect to a repository first.`); + } + + // const toolNames = [ + // 'fetch_documentation', + // 'search_documentation', + // 'search_code', + // 'fetch_generic_url_content' + // ] + const toolNames = tools.map(t => t.name); + + // const downstreamToolNames = [ + // 'fetch_query_documentation', + // 'search_query_documentation', + // 'search_query_code', + // 'fetch_generic_url_content' + // ] + const downstreamToolNames = await this.downstream?.listTools().then( + (tools) => { + return tools.tools.map(t => t.name); + } + ); + + // Map tool names to the actual GitMCP tool names and forward the call + // const toolMapping: Record = { + // "fetch_documentation": "fetch_query_documentation", + // "search_code": "search_query_code", + // "search_documentation": "search_query_documentation", + // "fetch_generic_url_content": "fetch_generic_url_content" + // }; + const toolMapping: Record = toolNames.reduce>((acc, routerToolName) => { + // Find matching downstream tool by checking if downstream tool contains all parts of router tool + const matchingDownstreamTool = downstreamToolNames?.find(downstreamTool => routerToolName.split("_").every(part => downstreamTool.includes(part))); + acc[routerToolName] = matchingDownstreamTool || routerToolName; + return acc; + }, {}); + + + const actualToolName = toolMapping[request.params.name]; + if (!actualToolName) { + throw new Error(`Unknown tool: ${request.params.name}`); + } + console.log("Tool mapping:", toolMapping); + + // Remove path from arguments when forwarding to downstream + const { path: _, ...forwardArgs } = request.params.arguments as any; + + return await this.downstream!.callTool({ + name: actualToolName, + arguments: forwardArgs + }); + + } catch (error) { + return { + content: [{ + type: "text", + text: `Error: ${error instanceof Error ? error.message : String(error)}` + }] + }; + } + }); + + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + if (!this.downstream) throw new Error("No repository loaded"); + return await this.downstream.listResources(); + }); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + if (!this.downstream) throw new Error("No repository loaded"); + return await this.downstream.readResource(request.params); + }); + } + + async start() { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } + + private async loadRepo(githubUrl: string) { + // Convert GitHub URL directly to GitMCP URL + const gitMcpUrl = normalizeToGitMcp(githubUrl); + + if (this.downstream) { + try { await this.downstream.close(); } catch { } + } + + const transport = new SSEClientTransport(new URL(gitMcpUrl)); + this.downstream = new Client({ name: "gitmcp-router-client", version: "0.1.0" }); + await this.downstream.connect(transport); + + this.currentRepo = githubUrl; + console.error(`Connected to GitMCP: ${githubUrl} -> ${gitMcpUrl}`); + + // Debug: List what tools are actually available from downstream + try { + const downstreamTools = await this.downstream.listTools(); + console.error(`Available downstream tools: ${JSON.stringify(downstreamTools.tools.map(t => t.name))}`); + } catch (e) { + console.error(`Error listing downstream tools: ${e}`); + } + } +} + +const router = new GitMcpRouter(); +router.start().catch((e) => { + console.error(e); + process.exit(1); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1592399 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src"] +}