diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index 85af62fd..62791b40 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -3,7 +3,7 @@ name = "mcp-git" version = "0.1.0" description = "A Model Context Protocol server providing tools to read, search, and manipulate Git repositories programmatically via LLMs" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.10" authors = [{ name = "Anthropic, PBC." }] maintainers = [{ name = "David Soria Parra", email = "davidsp@anthropic.com" }] keywords = ["git", "mcp", "llm", "automation"] @@ -13,7 +13,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.10", ] dependencies = [ "click>=8.1.7", @@ -30,5 +30,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.uv] -index-strategy = "unsafe-best-match" dev-dependencies = ["ruff>=0.7.3"] diff --git a/src/git/src/mcp_git/__init__.py b/src/git/src/mcp_git/__init__.py index 475404f9..3a9548c6 100644 --- a/src/git/src/mcp_git/__init__.py +++ b/src/git/src/mcp_git/__init__.py @@ -1,4 +1,6 @@ import logging +import json +import sys import click import anyio import anyio.lowlevel @@ -7,7 +9,15 @@ from mcp.server import Server from mcp.server.session import ServerSession from mcp.server.stdio import stdio_server -from mcp.types import TextContent, Tool, EmbeddedResource, ImageContent, ListRootsResult +from mcp.types import ( + ClientCapabilities, + TextContent, + Tool, + EmbeddedResource, + ImageContent, + ListRootsResult, + RootsCapability, +) from enum import StrEnum import git from git.objects import Blob, Tree @@ -66,6 +76,20 @@ class ListReposInput(BaseModel): pass +class GitLogInput(BaseModel): + repo_path: str + max_count: int = 10 + ref: str = "HEAD" + + +class ListBranchesInput(BaseModel): + repo_path: str + + +class ListTagsInput(BaseModel): + repo_path: str + + class GitTools(StrEnum): READ_FILE = "git_read_file" LIST_FILES = "git_list_files" @@ -75,12 +99,19 @@ class GitTools(StrEnum): GET_DIFF = "git_get_diff" GET_REPO_STRUCTURE = "git_get_repo_structure" LIST_REPOS = "git_list_repos" + GIT_LOG = "git_log" + LIST_BRANCHES = "git_list_branches" + LIST_TAGS = "git_list_tags" def git_read_file(repo: git.Repo, file_path: str, ref: str = "HEAD") -> str: tree = repo.commit(ref).tree blob = tree / file_path - return blob.data_stream.read().decode("utf-8", errors="replace") + try: + return blob.data_stream.read().decode("utf-8", errors="replace") + except UnicodeDecodeError: + # If it's a binary file, return a message indicating that + return "[Binary file content not shown]" def git_list_files(repo: git.Repo, path: str = "", ref: str = "HEAD") -> Sequence[str]: @@ -122,10 +153,14 @@ def git_search_code( tree = repo.commit(ref).tree for blob in tree.traverse(): if isinstance(blob, Blob) and Path(blob.path).match(file_pattern): - content = blob.data_stream.read().decode("utf-8") - for i, line in enumerate(content.splitlines()): - if query in line: - results.append(f"{blob.path}:{i+1}: {line}") + try: + content = blob.data_stream.read().decode("utf-8", errors="replace") + for i, line in enumerate(content.splitlines()): + if query in line: + results.append(f"{blob.path}:{i+1}: {line}") + except UnicodeDecodeError: + # Skip binary files + continue return results @@ -153,14 +188,35 @@ def build_tree(tree_obj: Tree) -> dict: return str(structure) +def git_log(repo: git.Repo, max_count: int = 10, ref: str = "HEAD") -> list[str]: + commits = list(repo.iter_commits(ref, max_count=max_count)) + log = [] + for commit in commits: + log.append( + f"Commit: {commit.hexsha}\n" + f"Author: {commit.author}\n" + f"Date: {commit.authored_datetime}\n" + f"Message: {commit.message}\n" + ) + return log + + +def git_list_branches(repo: git.Repo) -> list[str]: + return [str(branch) for branch in repo.branches] + + +def git_list_tags(repo: git.Repo) -> list[str]: + return [str(tag) for tag in repo.tags] + + async def serve(repository: Path | None) -> None: # Set up logging - logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) if repository is not None: try: git.Repo(repository) + logger.info(f"Using repository at {repository}") except git.InvalidGitRepositoryError: logger.error(f"{repository} is not a valid Git repository") return @@ -234,6 +290,28 @@ async def list_tools() -> list[Tool]: "accessible to the current session.", inputSchema=ListReposInput.schema(), ), + Tool( + name=GitTools.GIT_LOG, + description="Retrieves the commit log for the repository, showing the " + "history of commits including commit hashes, authors, dates, and " + "commit messages. This tool provides an overview of the project's " + "development history.", + inputSchema=GitLogInput.schema(), + ), + Tool( + name=GitTools.LIST_BRANCHES, + description="Lists all branches in the Git repository. This tool " + "provides an overview of the different lines of development in the " + "project.", + inputSchema=ListBranchesInput.schema(), + ), + Tool( + name=GitTools.LIST_TAGS, + description="Lists all tags in the Git repository. This tool " + "provides an overview of the tagged versions or releases in the " + "project.", + inputSchema=ListTagsInput.schema(), + ), ] async def list_repos() -> Sequence[str]: @@ -243,7 +321,14 @@ async def by_roots() -> Sequence[str]: "server.request_context.session must be a ServerSession" ) - roots_result: ListRootsResult = await server.request_context.session.list_roots() + if not server.request_context.session.check_client_capability( + ClientCapabilities(roots=RootsCapability()) + ): + return [] + + roots_result: ListRootsResult = ( + await server.request_context.session.list_roots() + ) logger.debug(f"Roots result: {roots_result}") repo_paths = [] for root in roots_result.roots: @@ -269,71 +354,123 @@ async def call_tool( ) -> list[TextContent | ImageContent | EmbeddedResource]: if name == GitTools.LIST_REPOS: result = await list_repos() - return [TextContent(type="text", text=str(r)) for r in result] + logging.debug(f"repos={result}") + return [ + TextContent( + type="text", + text=f"Here is some JSON that contains a list of git repositories: {json.dumps(result)}", + ) + ] repo_path = Path(arguments["repo_path"]) repo = git.Repo(repo_path) match name: case GitTools.READ_FILE: + content = git_read_file( + repo, arguments["file_path"], arguments.get("ref", "HEAD") + ) return [ TextContent( type="text", - text=git_read_file( - repo, arguments["file_path"], arguments.get("ref", "HEAD") - ) + text=f"Here is some JSON that contains the contents of a file: {json.dumps({'content': content})}", ) ] case GitTools.LIST_FILES: + files = git_list_files( + repo, arguments.get("path", ""), arguments.get("ref", "HEAD") + ) return [ - TextContent(type="text", text=str(f)) - for f in git_list_files( - repo, arguments.get("path", ""), arguments.get("ref", "HEAD") + TextContent( + type="text", + text=f"Here is some JSON that contains a list of files: {json.dumps({'files': list(files)})}", ) ] case GitTools.FILE_HISTORY: + history = git_file_history( + repo, arguments["file_path"], arguments.get("max_entries", 10) + ) return [ - TextContent(type="text", text=entry) - for entry in git_file_history( - repo, arguments["file_path"], arguments.get("max_entries", 10) + TextContent( + type="text", + text=f"Here is some JSON that contains a file's history: {json.dumps({'history': list(history)})}", ) ] case GitTools.COMMIT: result = git_commit(repo, arguments["message"], arguments.get("files")) - return [TextContent(type="text", text=result)] + return [ + TextContent( + type="text", + text=f"Here is some JSON that contains the commit result: {json.dumps({'result': result})}", + ) + ] case GitTools.SEARCH_CODE: + results = git_search_code( + repo, + arguments["query"], + arguments.get("file_pattern", "*"), + arguments.get("ref", "HEAD"), + ) return [ - TextContent(type="text", text=result) - for result in git_search_code( - repo, - arguments["query"], - arguments.get("file_pattern", "*"), - arguments.get("ref", "HEAD"), + TextContent( + type="text", + text=f"Here is some JSON that contains code search matches: {json.dumps({'matches': results})}", ) ] case GitTools.GET_DIFF: + diff = git_get_diff( + repo, + arguments["ref1"], + arguments["ref2"], + arguments.get("file_path"), + ) return [ TextContent( type="text", - text=git_get_diff( - repo, - arguments["ref1"], - arguments["ref2"], - arguments.get("file_path"), - ) + text=f"Here is some JSON that contains a diff: {json.dumps({'diff': diff})}", ) ] case GitTools.GET_REPO_STRUCTURE: + structure = git_get_repo_structure(repo, arguments.get("ref", "HEAD")) + return [ + TextContent( + type="text", + text=f"Here is some JSON that contains the repository structure: {json.dumps({'structure': structure})}", + ) + ] + + case GitTools.GIT_LOG: + log = git_log( + repo, arguments.get("max_count", 10), arguments.get("ref", "HEAD") + ) + return [ + TextContent( + type="text", + text=f"Here is some JSON that contains the git log: {json.dumps({'log': log})}", + ) + ] + + case GitTools.LIST_BRANCHES: + branches = git_list_branches(repo) + return [ + TextContent( + type="text", + text=f"Here is some JSON that contains a list of branches: {json.dumps({'branches': branches})}", + ) + ] + + case GitTools.LIST_TAGS: + tags = git_list_tags(repo) return [ TextContent( type="text", - text=git_get_repo_structure(repo, arguments.get("ref", "HEAD")) + text=f"Here is some JSON that contains a list of tags: {json.dumps({'tags': tags})}", ) ] @@ -348,7 +485,14 @@ async def call_tool( @click.command() @click.option("-r", "--repository", type=click.Path(path_type=Path, dir_okay=True)) -def main(repository: Path | None): +@click.option("-v", "--verbose", count=True) +def main(repository: Path | None, verbose: int): + logging_level = logging.WARN + if verbose == 1: + logging_level = logging.INFO + elif verbose >= 2: + logging_level = logging.DEBUG + logging.basicConfig(level=logging_level, stream=sys.stderr) anyio.run(serve, repository) diff --git a/src/git/uv.lock b/src/git/uv.lock index 456835c5..9d4e1a84 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -139,7 +139,7 @@ wheels = [ [[package]] name = "mcp" -version = "0.9.0" +version = "0.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -149,9 +149,9 @@ dependencies = [ { name = "sse-starlette" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/bb/fd56a5c331a6c95a4f2ec907683db3382d30b99b808ef6f46fa4f08a4b74/mcp-0.9.0.tar.gz", hash = "sha256:1d7e3f8d78bf5b37c98a233fce8cebbb86c57d8964d2c3b03cf08cdebd103d9a", size = 78343 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/1c/932818470ffd49c33509110c835101a8dc4c9cdd06028b9f647fb3dde237/mcp-0.9.1.tar.gz", hash = "sha256:e8509a37c2ab546095788ed170e0fb4d7ce0cf5a3ee56b6449c78af27321a425", size = 78218 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/07/077116e6a23dd0546391f5caa81b4f52938d8a81f2449c55c0b50c0215bf/mcp-0.9.0-py3-none-any.whl", hash = "sha256:e09aca08eadaf0552541aaa71271b44f99a6a5d16e5b1b03c421366f72b51753", size = 31691 }, + { url = "https://files.pythonhosted.org/packages/b3/a0/2ee813d456b57a726d583868417d1ad900fbe12ee3c8cd866e3e804ca486/mcp-0.9.1-py3-none-any.whl", hash = "sha256:7f640fcfb0be486aa510594df309920ae1d375cdca1f8aff21db3a96d837f303", size = 31562 }, ] [[package]] diff --git a/src/sqlite/src/sqlite/__init__.py b/src/sqlite/src/sqlite/__init__.py index 6217b1fe..8fc09807 100644 --- a/src/sqlite/src/sqlite/__init__.py +++ b/src/sqlite/src/sqlite/__init__.py @@ -1,9 +1,11 @@ from . import server import asyncio + def main(): """Main entry point for the package.""" asyncio.run(server.main()) + # Optionally expose other important items at package level -__all__ = ['main', 'server'] \ No newline at end of file +__all__ = ["main", "server"] diff --git a/src/sqlite/src/sqlite/server.py b/src/sqlite/src/sqlite/server.py index 3d36b495..ab7b6eed 100644 --- a/src/sqlite/src/sqlite/server.py +++ b/src/sqlite/src/sqlite/server.py @@ -28,7 +28,7 @@ def _init_database(self): if cursor.fetchone()[0] == 0: cursor.execute( "INSERT INTO notes (name, content) VALUES (?, ?)", - ("example", "This is an example note.") + ("example", "This is an example note."), ) conn.commit() @@ -55,13 +55,13 @@ def _add_note(self, name: str, content: str): with closing(conn.cursor()) as cursor: cursor.execute( "INSERT OR REPLACE INTO notes (name, content) VALUES (?, ?)", - (name, content) + (name, content), ) conn.commit() def __init__(self): super().__init__("sqlite") - + # Initialize SQLite database self.db_path = "notes.db" self._init_database() @@ -118,10 +118,14 @@ async def handle_get_prompt( """Generate a prompt using notes from the database""" if name != "summarize-notes": raise ValueError(f"Unknown prompt: {name}") - notes = "<notes>\n" + "\n".join( - f"<note name='{name}'>\n{content}\n</note>" - for name, content in self._get_notes().items() - ) + "\n</notes>" + notes = ( + "<notes>\n" + + "\n".join( + f"<note name='{name}'>\n{content}\n</note>" + for name, content in self._get_notes().items() + ) + + "\n</notes>" + ) style = (arguments or {}).get("style", "simple") prompt = """ Your task is to provide a summary of the notes provided below. @@ -207,4 +211,4 @@ async def main(): experimental_capabilities={}, ), ), - ) \ No newline at end of file + )