Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

major update to the git server #11

Merged
merged 4 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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