Skip to content

Commit

Permalink
major update to the git server
Browse files Browse the repository at this point in the history
  • Loading branch information
dsp-ant committed Nov 20, 2024
1 parent 56d2d1b commit bf3f3fd
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 48 deletions.
5 changes: 2 additions & 3 deletions src/git/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]" }]
keywords = ["git", "mcp", "llm", "automation"]
Expand All @@ -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",
Expand All @@ -30,5 +30,4 @@ requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
index-strategy = "unsafe-best-match"
dev-dependencies = ["ruff>=0.7.3"]
210 changes: 177 additions & 33 deletions src/git/src/mcp_git/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import logging
import json
import sys
import click
import anyio
import anyio.lowlevel
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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]:
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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:
Expand All @@ -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})}",
)
]

Expand All @@ -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)


Expand Down
6 changes: 3 additions & 3 deletions src/git/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/sqlite/src/sqlite/__init__.py
Original file line number Diff line number Diff line change
@@ -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']
__all__ = ["main", "server"]
Loading

0 comments on commit bf3f3fd

Please sign in to comment.