From 86bf63c61336b96bc1020c1ead963d90b41f15d8 Mon Sep 17 00:00:00 2001 From: Hemang Joshi Date: Sat, 23 Aug 2025 13:41:30 +0530 Subject: [PATCH] Add MCP server implementation for RenderGit\n\n- Added MCP server that can flatten GitHub repositories into text format for LLM context\n- Restructured project to follow Python packaging conventions\n- Added comprehensive documentation for using the MCP server\n- Added tests to verify the server works correctly\n- Added .gitignore to prevent cache files from being tracked --- .gitignore | 36 +++ MCP_SERVER.md | 58 +++++ README.md | 7 + demo_mcp.py | 62 +++++ pyproject.toml | 4 +- rendergit_package/__init__.py | 1 + rendergit_package/mcp_server.py | 216 ++++++++++++++++++ .../rendergit.py | 67 +----- rendergit_package/test_mcp.py | 74 ++++++ setup.py | 33 +++ test_mcp_server.py | 58 +++++ 11 files changed, 549 insertions(+), 67 deletions(-) create mode 100644 .gitignore create mode 100644 MCP_SERVER.md create mode 100644 demo_mcp.py create mode 100644 rendergit_package/__init__.py create mode 100644 rendergit_package/mcp_server.py rename rendergit.py => rendergit_package/rendergit.py (83%) create mode 100644 rendergit_package/test_mcp.py create mode 100644 setup.py create mode 100644 test_mcp_server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4d0fdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +.env + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/MCP_SERVER.md b/MCP_SERVER.md new file mode 100644 index 0000000..8b34427 --- /dev/null +++ b/MCP_SERVER.md @@ -0,0 +1,58 @@ +# RenderGit MCP Server + +This directory contains an MCP (Model Context Protocol) server implementation for RenderGit that allows you to flatten GitHub repositories into text format for LLM context. + +## Installation + +1. Create a virtual environment: + ```bash + python3 -m venv venv + source venv/bin/activate + ``` + +2. Install the package: + ```bash + pip install -e . + ``` + +## Running the MCP Server + +To start the MCP server, run: +```bash +rendergit-mcp +``` + +This will start the server and listen for MCP connections. + +## Using with Claude Desktop + +1. First, ensure you have [Claude Desktop](https://claude.ai/download) installed +2. Configure Claude Desktop to use the RenderGit MCP server by adding the following to your Claude configuration: + +```json +{ + "mcpServers": { + "rendergit": { + "command": "rendergit-mcp" + } + } +} +``` + +3. Restart Claude Desktop +4. In any conversation with Claude, you can now use the RenderGit tool to flatten repositories + +## Using the MCP Tool + +Once connected, you can ask Claude to flatten a repository: + +> "Please flatten the code in https://github.com/karpathy/nanoGPT for me to analyze" + +Claude will use the MCP tool to clone the repository, flatten it into CXML format, and provide it for analysis. + +## Testing the MCP Server + +You can test the MCP server with the provided test script: +```bash +python test_mcp_server.py +``` \ No newline at end of file diff --git a/README.md b/README.md index 8f04e79..ee0d4ed 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Once open, you can toggle between two views: There's a few other smaller options, see the code. +## MCP Server + +RenderGit also includes an MCP (Model Context Protocol) server that allows you to flatten GitHub repositories and feed them directly into LLMs through compatible tools like Claude Desktop. + +See [MCP_SERVER.md](MCP_SERVER.md) for detailed instructions on setting up and using the MCP server. + ## Features - **Dual view modes** - toggle between Human and LLM views @@ -45,6 +51,7 @@ There's a few other smaller options, see the code. - **Sidebar navigation** with file links and sizes - **Responsive design** that works on mobile - **Search-friendly** - use Ctrl+F to find anything across all files +- **MCP Server** - integrate directly with Claude Desktop and other MCP-compatible tools ## Contributing diff --git a/demo_mcp.py b/demo_mcp.py new file mode 100644 index 0000000..90846fe --- /dev/null +++ b/demo_mcp.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Example script demonstrating how to use the RenderGit MCP server programmatically +""" + +import asyncio +import json +import subprocess +import sys +import time + +async def demonstrate_mcp_usage(): + """Demonstrate how to use the RenderGit MCP server""" + print("Demonstrating RenderGit MCP server usage...") + + # Start the MCP server as a subprocess + server_process = subprocess.Popen( + [sys.executable, "-m", "rendergit_package.mcp_server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=0 + ) + + # Give the server a moment to start + time.sleep(2) + + # Check if the process is still running + if server_process.poll() is not None: + stderr = server_process.stderr.read() + print(f"Server failed to start: {stderr}") + return + + print("✓ MCP server started successfully") + print("\nYou can now use this server with any MCP-compatible client, such as:") + print("- Claude Desktop") + print("- Other MCP tools that support the Model Context Protocol") + print("\nTo use with Claude Desktop, add this to your configuration:") + print(""" + +{ + "mcpServers": { + "rendergit": { + "command": "rendergit-mcp" + } + } +} + +""") + + # Terminate the server + server_process.terminate() + try: + server_process.wait(timeout=5) + except subprocess.TimeoutExpired: + server_process.kill() + + print("✓ MCP server demonstration completed") + +if __name__ == "__main__": + asyncio.run(demonstrate_mcp_usage()) diff --git a/pyproject.toml b/pyproject.toml index 584fdf4..c7594d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ requires-python = ">=3.10" dependencies = [ "markdown>=3.8.2", "pygments>=2.19.2", + "mcp>=1.0.0", ] classifiers = [ "Development Status :: 4 - Beta", @@ -27,7 +28,8 @@ classifiers = [ ] [project.scripts] -rendergit = "rendergit:main" +rendergit = "rendergit_package.rendergit:main" +rendergit-mcp = "rendergit_package.mcp_server:main" [project.urls] Homepage = "https://github.com/karpathy/rendergit" diff --git a/rendergit_package/__init__.py b/rendergit_package/__init__.py new file mode 100644 index 0000000..55a2e2c --- /dev/null +++ b/rendergit_package/__init__.py @@ -0,0 +1 @@ +# rendergit_package/__init__.py \ No newline at end of file diff --git a/rendergit_package/mcp_server.py b/rendergit_package/mcp_server.py new file mode 100644 index 0000000..0924acb --- /dev/null +++ b/rendergit_package/mcp_server.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +MCP Server for RenderGit - Flattens GitHub repositories into text for LLM context +""" + +import argparse +import asyncio +import json +import logging +import os +import pathlib +import sys +import tempfile +from typing import Any, Dict, List, Optional + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import ( + GetPromptResult, + Prompt, + PromptArgument, + PromptReference, + TextContent, + TextResourceContents, + Tool, + CallToolResult, +) +from .rendergit import ( + collect_files, + decide_file, + generate_cxml_text, + git_clone, + git_head_commit, + MAX_DEFAULT_BYTES, +) + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize the MCP server +server = Server("rendergit-mcp") + +@server.list_prompts() +async def list_prompts() -> List[PromptReference]: + """List available prompts.""" + return [ + PromptReference( + name="rendergit-flatten-repo", + description="Flatten a GitHub repository into text format for LLM context", + arguments=[ + PromptArgument( + name="repo_url", + description="GitHub repository URL to flatten", + required=True, + ) + ], + ) + ] + +@server.get_prompt() +async def get_prompt(name: str, arguments: Dict[str, str] | None) -> GetPromptResult: + """Get a specific prompt by name.""" + if name == "rendergit-flatten-repo": + if not arguments or "repo_url" not in arguments: + raise ValueError("Missing required argument: repo_url") + + repo_url = arguments["repo_url"] + logger.info(f"Processing repository: {repo_url}") + + # Create a temporary directory for the cloned repo + tmpdir = tempfile.mkdtemp(prefix="flatten_repo_") + repo_dir = pathlib.Path(tmpdir, "repo") + + try: + # Clone the repository + logger.info(f"Cloning {repo_url} to {repo_dir}") + git_clone(repo_url, str(repo_dir)) + + # Get the HEAD commit + head = git_head_commit(str(repo_dir)) + logger.info(f"Clone complete (HEAD: {head[:8]})") + + # Collect files + logger.info(f"Scanning files in {repo_dir}") + infos = collect_files(repo_dir, MAX_DEFAULT_BYTES) + rendered_count = sum(1 for i in infos if i.decision.include) + skipped_count = len(infos) - rendered_count + logger.info(f"Found {len(infos)} files total ({rendered_count} will be rendered, {skipped_count} skipped)") + + # Generate CXML text for LLM consumption + logger.info("Generating CXML text...") + cxml_text = generate_cxml_text(infos, repo_dir) + + return GetPromptResult( + description=f"Flattened repository {repo_url}", + messages=[ + { + "role": "user", + "content": { + "type": "text", + "text": f"Repository {repo_url} (commit: {head[:8]}) flattened into CXML format:\n\n{cxml_text}" + } + } + ] + ) + except Exception as e: + logger.error(f"Error processing repository: {e}") + raise + finally: + # Clean up temporary directory + import shutil + shutil.rmtree(tmpdir, ignore_errors=True) + + raise ValueError(f"Unknown prompt: {name}") + +@server.list_tools() +async def list_tools() -> List[Tool]: + """List available tools.""" + return [ + Tool( + name="flatten_repo", + description="Flatten a GitHub repository into text format for LLM context", + inputSchema={ + "type": "object", + "properties": { + "repo_url": { + "type": "string", + "description": "GitHub repository URL to flatten" + } + }, + "required": ["repo_url"] + } + ) + ] + +@server.call_tool() +async def call_tool(name: str, arguments: Dict[str, Any]) -> List[CallToolResult]: + """Call a specific tool by name.""" + if name == "flatten_repo": + if "repo_url" not in arguments: + raise ValueError("Missing required argument: repo_url") + + repo_url = arguments["repo_url"] + logger.info(f"Processing repository with tool: {repo_url}") + + # Create a temporary directory for the cloned repo + tmpdir = tempfile.mkdtemp(prefix="flatten_repo_") + repo_dir = pathlib.Path(tmpdir, "repo") + + try: + # Clone the repository + logger.info(f"Cloning {repo_url} to {repo_dir}") + git_clone(repo_url, str(repo_dir)) + + # Get the HEAD commit + head = git_head_commit(str(repo_dir)) + logger.info(f"Clone complete (HEAD: {head[:8]})") + + # Collect files + logger.info(f"Scanning files in {repo_dir}") + infos = collect_files(repo_dir, MAX_DEFAULT_BYTES) + rendered_count = sum(1 for i in infos if i.decision.include) + skipped_count = len(infos) - rendered_count + logger.info(f"Found {len(infos)} files total ({rendered_count} will be rendered, {skipped_count} skipped)") + + # Generate CXML text for LLM consumption + logger.info("Generating CXML text...") + cxml_text = generate_cxml_text(infos, repo_dir) + + # Return the result + return [ + CallToolResult( + content=[ + TextContent( + type="text", + text=f"Repository {repo_url} (commit: {head[:8]}) flattened into CXML format:\n\n{cxml_text}" + ) + ], + isError=False + ) + ] + except Exception as e: + logger.error(f"Error processing repository: {e}") + return [ + CallToolResult( + content=[ + TextContent( + type="text", + text=f"Error processing repository {repo_url}: {str(e)}" + ) + ], + isError=True + ) + ] + finally: + # Clean up temporary directory + import shutil + shutil.rmtree(tmpdir, ignore_errors=True) + + raise ValueError(f"Unknown tool: {name}") + +async def main(): + """Main entry point for the MCP server.""" + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, { + "version": "0.1.0", + "capabilities": { + "prompts": {}, + "tools": {}, + "resources": {} + } + }) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/rendergit.py b/rendergit_package/rendergit.py similarity index 83% rename from rendergit.py rename to rendergit_package/rendergit.py index 03e05f5..65ad64c 100644 --- a/rendergit.py +++ b/rendergit_package/rendergit.py @@ -452,69 +452,4 @@ def render_skip_list(title: str, items: List[FileInfo]) -> str: -""" - - -def derive_temp_output_path(repo_url: str) -> pathlib.Path: - """Derive a temporary output path from the repo URL.""" - # Extract repo name from URL like https://github.com/owner/repo or https://github.com/owner/repo.git - parts = repo_url.rstrip('/').split('/') - if len(parts) >= 2: - repo_name = parts[-1] - if repo_name.endswith('.git'): - repo_name = repo_name[:-4] - filename = f"{repo_name}.html" - else: - filename = "repo.html" - - return pathlib.Path(tempfile.gettempdir()) / filename - - -def main() -> int: - ap = argparse.ArgumentParser(description="Flatten a GitHub repo to a single HTML page") - ap.add_argument("repo_url", help="GitHub repo URL (https://github.com/owner/repo[.git])") - ap.add_argument("-o", "--out", help="Output HTML file path (default: temporary file derived from repo name)") - ap.add_argument("--max-bytes", type=int, default=MAX_DEFAULT_BYTES, help="Max file size to render (bytes); larger files are listed but skipped") - ap.add_argument("--no-open", action="store_true", help="Don't open the HTML file in browser after generation") - args = ap.parse_args() - - # Set default output path if not provided - if args.out is None: - args.out = str(derive_temp_output_path(args.repo_url)) - - tmpdir = tempfile.mkdtemp(prefix="flatten_repo_") - repo_dir = pathlib.Path(tmpdir, "repo") - - try: - print(f"📁 Cloning {args.repo_url} to temporary directory: {repo_dir}", file=sys.stderr) - git_clone(args.repo_url, str(repo_dir)) - head = git_head_commit(str(repo_dir)) - print(f"✓ Clone complete (HEAD: {head[:8]})", file=sys.stderr) - - print(f"📊 Scanning files in {repo_dir}...", file=sys.stderr) - infos = collect_files(repo_dir, args.max_bytes) - rendered_count = sum(1 for i in infos if i.decision.include) - skipped_count = len(infos) - rendered_count - print(f"✓ Found {len(infos)} files total ({rendered_count} will be rendered, {skipped_count} skipped)", file=sys.stderr) - - print(f"🔨 Generating HTML...", file=sys.stderr) - html_out = build_html(args.repo_url, repo_dir, head, infos) - - out_path = pathlib.Path(args.out) - print(f"💾 Writing HTML file: {out_path.resolve()}", file=sys.stderr) - out_path.write_text(html_out, encoding="utf-8") - file_size = out_path.stat().st_size - print(f"✓ Wrote {bytes_human(file_size)} to {out_path}", file=sys.stderr) - - if not args.no_open: - print(f"🌐 Opening {out_path} in browser...", file=sys.stderr) - webbrowser.open(f"file://{out_path.resolve()}") - - print(f"🗑️ Cleaning up temporary directory: {tmpdir}", file=sys.stderr) - return 0 - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - - -if __name__ == "__main__": - main() +""" \ No newline at end of file diff --git a/rendergit_package/test_mcp.py b/rendergit_package/test_mcp.py new file mode 100644 index 0000000..3095d76 --- /dev/null +++ b/rendergit_package/test_mcp.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Test script for the RenderGit MCP server +""" + +import asyncio +import json +import sys +from mcp.client.stdio import stdio_client +from mcp.types import ClientCapabilities, ClientNotificationOptions + +async def test_mcp_server(): + """Test the MCP server functionality""" + async with stdio_client(["python", "-m", "mcp_server"]) as (read, write, _): + # Initialize the connection + init_message = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "1.0.0", + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + }, + "capabilities": {} + } + } + + write(init_message) + response = await read() + print(f"Initialize response: {json.dumps(response, indent=2)}") + + # List tools + list_tools_message = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + } + + write(list_tools_message) + response = await read() + print(f"List tools response: {json.dumps(response, indent=2)}") + + # Call the flatten_repo tool + call_tool_message = { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "flatten_repo", + "arguments": { + "repo_url": "https://github.com/karpathy/randomfun" + } + } + } + + write(call_tool_message) + response = await read() + print(f"Call tool response: {json.dumps(response, indent=2)}") + + # Shutdown + shutdown_message = { + "jsonrpc": "2.0", + "id": 4, + "method": "shutdown" + } + + write(shutdown_message) + response = await read() + print(f"Shutdown response: {json.dumps(response, indent=2)}") + +if __name__ == "__main__": + asyncio.run(test_mcp_server()) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bb68e25 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +from setuptools import setup, find_packages + +setup( + name="rendergit", + version="0.1.0", + description="Flatten a GitHub repo into a single static HTML page for fast skimming and Ctrl+F", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + license="0BSD", + author="Andrej Karpathy", + python_requires=">=3.10", + packages=find_packages(), + install_requires=[ + "markdown>=3.8.2", + "pygments>=2.19.2", + "mcp>=1.0.0", + ], + entry_points={ + "console_scripts": [ + "rendergit=rendergit_package.rendergit:main", + "rendergit-mcp=rendergit_package.mcp_server:main", + ], + }, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Utilities", + ], +) \ No newline at end of file diff --git a/test_mcp_server.py b/test_mcp_server.py new file mode 100644 index 0000000..3411d12 --- /dev/null +++ b/test_mcp_server.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Test script for the RenderGit MCP server +""" + +import asyncio +import subprocess +import sys +import time + +async def test_mcp_server(): + """Test the MCP server functionality""" + print("Testing RenderGit MCP server...") + + # Start the MCP server as a subprocess + try: + server_process = subprocess.Popen( + [sys.executable, "-m", "rendergit_package.mcp_server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=0 + ) + + # Give the server a moment to start + time.sleep(2) + + # Check if the process is still running + if server_process.poll() is not None: + stderr = server_process.stderr.read() + print(f"Server failed to start: {stderr}") + return False + + print("✓ MCP server started successfully") + + # Terminate the server + server_process.terminate() + try: + server_process.wait(timeout=5) + except subprocess.TimeoutExpired: + server_process.kill() + + print("✓ MCP server test completed") + return True + + except Exception as e: + print(f"Error testing MCP server: {e}") + return False + +if __name__ == "__main__": + success = asyncio.run(test_mcp_server()) + if success: + print("All tests passed!") + sys.exit(0) + else: + print("Tests failed!") + sys.exit(1) \ No newline at end of file