Source code for smarter.apps.mcpserver.github.tools
"""
The tools that search, open and comment on the GitHub issues of The Smarter Project.
- ``search_issues``: the GitHub issues of the repositories, live. Anyone may call it.
- ``read_issue``: one issue and its latest comments, live. Anyone may call it.
- ``create_issue``: opens a GitHub issue in a repository, as the caller, optionally with labels.
- ``comment_on_issue``: adds a comment to an existing issue, as the caller.
- ``close_issue``: closes an issue that a merged pull request resolved, with a comment, as the caller.
- ``create_pull_request``: commits a change to a new branch, and opens a pull request, as the caller.
``create_issue``, ``comment_on_issue``, ``close_issue`` and ``create_pull_request`` are served only when the
request carries the caller's GitHub classic personal access token, and their results are never
cached. See :mod:`smarter.apps.mcpserver.github.issues` and :mod:`smarter.apps.mcpserver.github.pulls`.
.. note::
**Experimental.** The MCPServer was designed and coded by Claude Code (Anthropic's
Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
be documented.
"""
from typing import Optional, cast
from pydantic import Field
from smarter.apps.mcpserver.exceptions import SmarterMCPServerToolError
from smarter.apps.mcpserver.github import issues, pulls
from smarter.apps.mcpserver.github.issues import (
GitHubIssueComment,
GitHubIssueHit,
GitHubIssueThread,
IssueState,
)
from smarter.apps.mcpserver.github.pulls import GitHubCommitAuthor, PullRequestFile
from smarter.apps.mcpserver.protocol.tools import (
DEFAULT_SEARCH_LIMIT,
MAX_READ_CHARACTERS,
MAX_SEARCH_LIMIT,
SmarterProjectTool,
ToolArguments,
)
from smarter.apps.mcpserver.repositories import ProjectRepository
# -----------------------------------------------------------------------------
# search_issues
# -----------------------------------------------------------------------------
ISSUE_SEARCH_CACHE_TTL = 60
"""Seconds that search_issues caches a result: issues change by the minute."""
class SearchIssuesArguments(ToolArguments):
"""The arguments of search_issues."""
query: str = Field(
min_length=1,
max_length=200,
description="Keywords, e.g. guardrail apply error. GitHub's qualifiers, e.g. label:bug, are allowed.",
)
state: IssueState = Field(default="all", description="open, closed, or all.")
limit: int = Field(
default=DEFAULT_SEARCH_LIMIT, ge=1, le=MAX_SEARCH_LIMIT, description="The maximum number of issues."
)
[docs]
class SearchIssuesTool(SmarterProjectTool):
"""Search the repositories' GitHub issues, live."""
name = "search_issues"
title = "Search GitHub issues"
description = (
"Search the GitHub issues of The Smarter Project's repositories: known bugs, feature requests, "
"and whether they are fixed. Returns the best matches, with their state, number of comments, "
"and url. Use it when a user reports a problem, and always before create_issue, so that an issue "
"is not opened twice: comment on an existing one instead."
)
arguments_model = SearchIssuesArguments
open_world = True
cache_ttl = ISSUE_SEARCH_CACHE_TTL
[docs]
def run(self, arguments: ToolArguments) -> str:
arguments = cast(SearchIssuesArguments, arguments)
repository = self.scope.resolve(arguments.repository, required=False)
repositories = (repository,) if repository else self.scope.repositories
hits = issues.search_issues(
repositories,
arguments.query,
one=repository is not None,
state=arguments.state,
limit=arguments.limit,
token=self.github_token,
)
where = repository.full_name if repository else "The Smarter Project's repositories"
if not hits:
return f'No issue of {where} matches "{arguments.query}". Try other keywords.'
results = "\n\n".join(self.format_hit(number, hit) for number, hit in enumerate(hits, start=1))
return f'{len(hits)} issues of {where}, for "{arguments.query}", best first.\n\n{results}'
[docs]
@staticmethod
def format_hit(number: int, hit: GitHubIssueHit) -> str:
"""Return one issue, as Markdown."""
heading = (
f"{number}. **{hit.title}** {hit.repository}#{hit.number}, {hit.state}, "
f"{hit.comments} comments, updated {hit.updated_at[:10]}\n {hit.html_url}"
)
return f"{heading}\n {hit.excerpt}" if hit.excerpt else heading
# -----------------------------------------------------------------------------
# read_issue
# -----------------------------------------------------------------------------
class ReadIssueArguments(ToolArguments):
"""The arguments of read_issue."""
issue_number: int = Field(ge=1, description="The issue's number, from search_issues.")
[docs]
class ReadIssueTool(SmarterProjectTool):
"""Read a GitHub issue and its latest comments, live."""
name = "read_issue"
title = "Read GitHub issue"
description = (
"Read one GitHub issue of a repository of The Smarter Project: its state, labels and body, and its "
"latest comments, oldest first. Call it before comment_on_issue, so that a comment adds what the "
"issue does not already say, and to see whether a closed issue was fixed or declined."
)
arguments_model = ReadIssueArguments
requires_repository = True
open_world = True
cache_ttl = ISSUE_SEARCH_CACHE_TTL
[docs]
def run(self, arguments: ToolArguments) -> str:
arguments = cast(ReadIssueArguments, arguments)
repository = cast(ProjectRepository, self.scope.resolve(arguments.repository, required=True))
thread = issues.read_issue(repository, arguments.issue_number, self.github_token)
return self.format_thread(repository, thread)
[docs]
@staticmethod
def format_comment(comment: GitHubIssueComment) -> str:
"""Return one comment, as Markdown."""
author = comment.user.login if comment.user else "unknown"
return f"### {author}, {comment.created_at[:10]}\n\n{(comment.body or '').strip()}"
[docs]
@classmethod
def format_thread(cls, repository: ProjectRepository, thread: GitHubIssueThread) -> str:
"""
Return the issue and its comments, as Markdown, within :data:`MAX_READ_CHARACTERS`.
When the result is too long, the issue's body is shortened first, and then the oldest
comments are left out, because the latest say what is known now.
"""
issue = thread.issue
kind = "Pull request" if issue.pull_request is not None else "Issue"
state = issue.state + (f" ({issue.state_reason})" if issue.state_reason else "")
if isinstance(issue.pull_request, dict):
merged_at = issue.pull_request.get("merged_at")
state += f", merged {str(merged_at)[:10]}" if merged_at else ", not merged"
closed = f", closed {issue.closed_at[:10]}" if issue.closed_at else ""
labels = ", ".join(label.name for label in issue.labels) or "none"
author = issue.user.login if issue.user else "unknown"
heading = (
f"# {kind} {repository.full_name}#{issue.number}: {issue.title}\n\n"
f"{issue.html_url}\n{state}, opened by {author} {issue.created_at[:10]}{closed}, "
f"updated {issue.updated_at[:10]}, labels: {labels}, {issue.comments} comments"
)
body = (issue.body or "").strip()
comments = [cls.format_comment(comment) for comment in thread.comments]
omitted = issue.comments - len(comments)
budget = MAX_READ_CHARACTERS - len(heading) - 200
body = body if len(body) <= budget // 2 else body[: budget // 2] + "\n\n[... the rest of the body is left out]"
budget -= len(body)
kept: list[str] = []
for comment in reversed(comments):
if len(comment) + 2 > budget:
break
kept.insert(0, comment)
budget -= len(comment) + 2
omitted += len(comments) - len(kept)
parts = [heading, body or "(no description)"]
if omitted:
parts.append(f"[{omitted} earlier comments are left out]")
parts.extend(kept)
return "\n\n".join(parts)
# -----------------------------------------------------------------------------
# create_issue
# -----------------------------------------------------------------------------
MAX_LABELS = 10
MAX_LABEL_LENGTH = 50
class CreateIssueArguments(ToolArguments):
"""The arguments of create_issue."""
title: str = Field(
min_length=1,
max_length=issues.MAX_TITLE_LENGTH,
description="The issue's title: one specific line, e.g. smarter apply fails on a Guardrail with no rules.",
)
body: str = Field(
min_length=1,
max_length=issues.MAX_BODY_LENGTH,
description=(
"The issue's body, in Markdown: what happened, what was expected, how to reproduce it, and the "
"versions involved, or for a feature request, the problem that it solves."
),
)
labels: list[str] = Field(
default_factory=list,
max_length=MAX_LABELS,
description=(
"Labels, e.g. bug or severity: high. Use the repository's existing labels where they fit. GitHub "
"drops them if the user cannot push to the repository."
),
)
[docs]
class CreateIssueTool(SmarterProjectTool):
"""Open a GitHub issue in a repository, as the caller."""
name = "create_issue"
title = "Create GitHub issue"
description = (
"Open a GitHub issue in a repository of The Smarter Project, under the user's own GitHub account, "
"to report a bug or request a feature. It creates a public issue that maintainers are notified of: "
"call it only when the user asks you to, after showing them the repository, title and body, and "
"never more than once for the same issue. Search the code and documentation first, so that the "
"issue is specific, choose the repository that owns the problem, and call search_issues first: "
"if the issue already exists, use comment_on_issue instead."
)
arguments_model = CreateIssueArguments
requires_repository = True
read_only = False
requires_github_token = True
open_world = True
[docs]
def run(self, arguments: ToolArguments) -> str:
arguments = cast(CreateIssueArguments, arguments)
repository = cast(ProjectRepository, self.scope.resolve(arguments.repository, required=True))
labels = tuple(label.strip() for label in arguments.labels if label.strip())
too_long = [label for label in labels if len(label) > MAX_LABEL_LENGTH]
if too_long:
raise SmarterMCPServerToolError(f"A label is at most {MAX_LABEL_LENGTH} characters: {too_long[0][:60]}")
issue = issues.create_issue(
repository, arguments.title.strip(), arguments.body.strip(), self.github_token, labels=labels
)
author = f" by {issue.author}" if issue.author else ""
return f"Opened issue #{issue.number} in {repository.full_name}{author}: {issue.title}\n{issue.html_url}"
# -----------------------------------------------------------------------------
# comment_on_issue
# -----------------------------------------------------------------------------
class CommentOnIssueArguments(ToolArguments):
"""The arguments of comment_on_issue."""
issue_number: int = Field(ge=1, description="The issue's number, from search_issues.")
body: str = Field(
min_length=1,
max_length=issues.MAX_BODY_LENGTH,
description=(
"The comment, in Markdown: what it adds to the issue, e.g. another way to reproduce it, a "
"version that is affected, or a workaround."
),
)
[docs]
class CommentOnIssueTool(SmarterProjectTool):
"""Add a comment to a GitHub issue, as the caller."""
name = "comment_on_issue"
title = "Comment on GitHub issue"
description = (
"Add a comment to an existing GitHub issue of a repository of The Smarter Project, under the user's "
"own GitHub account, e.g. to confirm a bug that search_issues found, rather than open it again. "
"Read the issue with read_issue first, and comment only to add what it does not already say. "
"It is public, and the issue's participants are notified: call it only when the user asks you to, "
"after showing them the issue and the comment, and never more than once for the same comment."
)
arguments_model = CommentOnIssueArguments
requires_repository = True
read_only = False
requires_github_token = True
open_world = True
[docs]
def run(self, arguments: ToolArguments) -> str:
arguments = cast(CommentOnIssueArguments, arguments)
repository = cast(ProjectRepository, self.scope.resolve(arguments.repository, required=True))
comment = issues.comment_on_issue(repository, arguments.issue_number, arguments.body.strip(), self.github_token)
author = f" by {comment.author}" if comment.author else ""
return f"Commented on {repository.full_name}#{arguments.issue_number}{author}.\n{comment.html_url}"
# -----------------------------------------------------------------------------
# close_issue
# -----------------------------------------------------------------------------
class CloseIssueArguments(ToolArguments):
"""The arguments of close_issue."""
issue_number: int = Field(ge=1, description="The number of the issue to close.")
pull_request_number: int = Field(
ge=1, description="The number of the merged pull request, in the same repository, that resolved the issue."
)
comment: str = Field(
min_length=1,
max_length=issues.MAX_BODY_LENGTH,
description=(
"Why the issue is resolved, in Markdown: the pull request, what it changed, and the evidence that "
"the problem is gone, e.g. the release that contains it."
),
)
[docs]
class CloseIssueTool(SmarterProjectTool):
"""Close an issue that a merged pull request resolved, as the caller."""
name = "close_issue"
title = "Close GitHub issue"
description = (
"Close an open GitHub issue of a repository of The Smarter Project, under the user's own GitHub "
"account, when a merged pull request resolved it. It adds the comment, which says why, and closes the "
"issue as completed, only if GitHub confirms that the pull request was merged. Read the issue and the "
"pull request with read_issue first, and close the issue only if the pull request fixes exactly what "
"the issue reports. It is public, and the issue's participants are notified: call it only when the "
"user asks you to."
)
arguments_model = CloseIssueArguments
requires_repository = True
read_only = False
requires_github_token = True
open_world = True
[docs]
def run(self, arguments: ToolArguments) -> str:
arguments = cast(CloseIssueArguments, arguments)
repository = cast(ProjectRepository, self.scope.resolve(arguments.repository, required=True))
closed = issues.close_issue(
repository,
arguments.issue_number,
arguments.pull_request_number,
arguments.comment.strip(),
self.github_token,
)
author = f" by {closed.author}" if closed.author else ""
return (
f"Closed {repository.full_name}#{closed.number}{author}, resolved by "
f"#{arguments.pull_request_number}.\n{closed.html_url}\nComment: {closed.comment_url}"
)
# -----------------------------------------------------------------------------
# create_pull_request
# -----------------------------------------------------------------------------
class CreatePullRequestArguments(ToolArguments):
"""The arguments of create_pull_request."""
branch: str = Field(
min_length=1,
max_length=pulls.MAX_BRANCH_LENGTH,
description="The new branch, which must not exist, e.g. claude/fix-plugin-timeout.",
)
base: Optional[str] = Field(
default=None,
max_length=pulls.MAX_BRANCH_LENGTH,
description="The branch to merge into, and to branch from. Omit it for the repository's default branch.",
)
title: str = Field(
min_length=1, max_length=issues.MAX_TITLE_LENGTH, description="The pull request's title: one specific line."
)
body: str = Field(
min_length=1,
max_length=issues.MAX_BODY_LENGTH,
description=(
"The pull request's description, in Markdown: the problem, the cause, the change, how it was "
"verified, and the issue that it fixes, e.g. Fixes #42."
),
)
commit_message: str = Field(
min_length=1,
max_length=pulls.MAX_COMMIT_MESSAGE_LENGTH,
description="The commit's message: a conventional-commit subject line, a blank line, and a body.",
)
files: list[PullRequestFile] = Field(
min_length=1,
max_length=pulls.MAX_FILES,
description=(
"The changed files. Each is {path, content}, with the file's whole new content, not a diff, or "
"{path, delete: true}. Read each file first, and change only what the fix needs."
),
)
author: Optional[GitHubCommitAuthor] = Field(
default=None,
description="The commit's author and committer, {name, email}. Omit it for the owner of the GitHub token.",
)
draft: bool = Field(default=False, description="Whether to open it as a draft.")
[docs]
class CreatePullRequestTool(SmarterProjectTool):
"""Commit a change to a new branch, and open a pull request, as the caller."""
name = "create_pull_request"
title = "Create GitHub pull request"
description = (
"Propose a change to a repository of The Smarter Project, under the user's own GitHub account: one "
"commit, with each changed file's whole new content, on a new branch from base, and a pull request "
"from it. It never changes an existing branch, nor files under .github/. It is public, and maintainers "
"are notified: call it only when the user asks you to, after showing them the files, title and "
"description, and never twice for the same change. Read every file that you change with read_file "
"first, from the base branch, and keep the change as small as the fix."
)
arguments_model = CreatePullRequestArguments
requires_repository = True
read_only = False
requires_github_token = True
open_world = True
[docs]
def run(self, arguments: ToolArguments) -> str:
arguments = cast(CreatePullRequestArguments, arguments)
repository = cast(ProjectRepository, self.scope.resolve(arguments.repository, required=True))
pull_request = pulls.create_pull_request(
repository,
branch=arguments.branch.strip(),
title=arguments.title.strip(),
body=arguments.body.strip(),
commit_message=arguments.commit_message.strip(),
files=arguments.files,
token=self.github_token,
base=arguments.base.strip() if arguments.base and arguments.base.strip() else None,
author=arguments.author,
draft=arguments.draft,
)
author = f" by {pull_request.author}" if pull_request.author else ""
draft = " draft" if pull_request.draft else ""
return (
f"Opened{draft} pull request #{pull_request.number} in {repository.full_name}{author}: "
f"{pull_request.title}\n{pull_request.html_url}\n"
f"{pull_request.branch} into {pull_request.base}, commit {pull_request.commit_sha[:12]}"
)
__all__ = [
"CloseIssueTool",
"CommentOnIssueTool",
"CreateIssueTool",
"CreatePullRequestTool",
"ReadIssueTool",
"SearchIssuesTool",
]