Source code for smarter.apps.mcpserver.protocol.tools.reading
"""
The public tools that read the index: ``get_repository_overview``, ``read_file``, ``list_files`` and ``read_blog_post``.
Each tool validates its arguments, calls :mod:`smarter.apps.mcpserver.services.reading`, and formats
what it returns as Markdown. They are served at the MCPServer's public endpoint
(:mod:`smarter.apps.mcpserver.protocol.public`). ``read_blog_post`` has no ``repository`` argument,
and only the endpoint of every repository serves it. Every tool is read-only, so
:meth:`~smarter.apps.mcpserver.protocol.tools.public.PublicTool.call` caches its results until an
index changes.
A file, folder or post that does not exist is a tool error that suggests what the LLM may have
meant, or else which tool finds it.
.. 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 (
SmarterMCPServerNotFoundError,
SmarterMCPServerToolError,
)
from smarter.apps.mcpserver.protocol.tools.markdown import code_fence_language
from smarter.apps.mcpserver.protocol.tools.public import PublicTool, PublicToolArguments
from smarter.apps.mcpserver.services.reading import (
MAX_READ_LINES,
FolderEntry,
ReadLines,
list_files,
read_blog_post,
read_file,
repositories_state,
repository_overview,
)
from smarter.apps.mcpserver.services.repositories import (
SMARTER_PROJECT_DOCS_ROOT_URL,
ProjectRepository,
project_blog,
)
def continuation(tool_name: str, noun: str, lines: ReadLines) -> str:
"""
Return the note that tells the LLM how to read the rest of a file or post.
:param tool_name: The tool that reads on, e.g. ``read_file``.
:param noun: What is read, e.g. ``file`` or ``post``.
:param lines: The lines that were read.
:returns: The note, which starts with a blank line, or an empty string if the last line was read.
"""
if lines.is_complete:
return ""
next_line = lines.end + 1
note = f"\n\nThe {noun} continues: call {tool_name} with start_line={next_line}."
return note
def not_found(error: SmarterMCPServerNotFoundError, hint: str) -> SmarterMCPServerToolError:
"""
Return the tool error for something that does not exist, with a hint when there is nothing to suggest.
:param error: The service's error, whose message names what was not found, and suggests what
the LLM may have meant.
:param hint: What to do when there are no suggestions, e.g. ``Use search_blog to find it.``
:returns: The tool error, for the caller to raise ``from error``.
"""
message = error.message
if not error.suggestions:
message += f" {hint}"
tool_error = SmarterMCPServerToolError(message)
return tool_error
def entry_items(entries: list[FolderEntry], hidden: int) -> list[str]:
"""
Return the folders and files of a listing as Markdown list items.
:param entries: The folders and files.
:param hidden: The number of entries left out.
:returns: One item per entry, the folders with their number of files and the files with their
sizes, and a last item that counts the entries left out, if any.
"""
items = []
for entry in entries:
if entry.is_folder:
items.append(f"- {entry.name}/ ({entry.files} files)")
else:
items.append(f"- {entry.name} ({entry.size} bytes)")
if hidden:
items.append(f"- ... and {hidden} more")
return items
# -----------------------------------------------------------------------------
# get_repository_overview
# -----------------------------------------------------------------------------
[docs]
class GetRepositoryOverviewTool(PublicTool):
"""
The ``get_repository_overview`` tool: what a repository contains, how it is laid out, and its README.
Without a repository, it lists every repository of the endpoint, with whether each is indexed.
"""
name = "get_repository_overview"
title = "Repository overview"
description = (
"Describe a repository of The Smarter Project: what it contains, its GitHub url, its indexed "
"commit, its top-level folders and files, and its README. Use it for questions about a repository "
"as a whole, not to answer a specific question, which the search tools answer directly."
)
[docs]
def run(self, arguments: PublicToolArguments) -> str:
"""
Describe the named repository, with :func:`~smarter.apps.mcpserver.services.reading.repository_overview`, or list them all if none is named.
**Side effects:** database queries, and, for a repository that is not indexed yet, a Celery task
queued to index it.
:param arguments: The validated arguments.
:returns: The overview, as Markdown.
:raises SmarterMCPServerToolError: If the repository is not one of the endpoint's.
:raises SmarterMCPServerServiceError: If the repository is not indexed yet.
"""
repository = self.scope.resolve(arguments.repository, required=False)
if repository is None:
return self.all_repositories()
overview = repository_overview(repository)
indexed_at = f"{overview.indexed_at:%Y-%m-%d %H:%M}"
top_level = entry_items(overview.top_level, overview.top_level_hidden)
parts = [
f"# {repository.name}: {repository.title}",
repository.description,
f"- GitHub: {repository.github_url}",
]
if repository.docs_url:
parts.append(f"- Published documentation: {repository.docs_url}, built from {repository.docs_root}/")
parts.append(
f"- Indexed commit: {overview.commit_sha} of {repository.ref}, "
f"{indexed_at} UTC: {overview.file_count} files, "
f"{overview.documentation_files} of documentation and {overview.code_files} of code"
)
parts.append("\n## Top-level folders and files\n\n" + "\n".join(top_level))
if overview.readme_path:
excerpt = overview.readme
if overview.readme_truncated:
excerpt += f"\n\n[README truncated: read the rest with read_file, path {overview.readme_path}]"
parts.append(f"\n## {overview.readme_path}\n\n{excerpt}")
text = "\n".join(parts)
return text
[docs]
def all_repositories(self) -> str:
"""
Return a table of every repository of the scope, with whether each one is indexed.
**Side effects:** see :func:`~smarter.apps.mcpserver.services.reading.repositories_state`.
:returns: The table, as Markdown, and where the documentation and the blog are.
"""
states = repositories_state(self.scope.repositories)
rows = ["| Repository | Contents | Indexed |", "| --- | --- | --- |"]
for state in states:
repository = state.repository
if state.is_indexed:
indexed = f"{state.commit_sha[:7]}, {state.file_count} files"
else:
indexed = "not yet"
rows.append(f"| [{repository.name}]({repository.github_url}) | {repository.description} | {indexed} |")
table = "\n".join(rows)
blog_url = project_blog().url
overview = (
"# The Smarter Project's repositories\n\n"
+ table
+ f"\n\nThe documentation, {SMARTER_PROJECT_DOCS_ROOT_URL}, is built from the smarter "
f"repository's docs/source. The blog, {blog_url}, is searched with search_blog. "
"Name a repository to see its layout and README."
)
return overview
# -----------------------------------------------------------------------------
# read_file
# -----------------------------------------------------------------------------
class ReadFileArguments(PublicToolArguments):
"""The arguments of read_file."""
path: str = Field(min_length=1, max_length=512, description="The file's path in the repository, e.g. README.md.")
start_line: int = Field(default=1, ge=1, description="The first line to read.")
end_line: Optional[int] = Field(
default=None, ge=1, description=f"The last line to read. At most {MAX_READ_LINES} lines are read at once."
)
[docs]
class ReadFileTool(PublicTool):
"""The ``read_file`` tool: a file of a repository, or a range of its lines, with line numbers."""
name = "read_file"
title = "Read file"
description = (
"Read a file of a repository of The Smarter Project, with line numbers, from the paths that the "
f"search tools and list_files return. Long files are read {MAX_READ_LINES} lines at a time: use "
"start_line and end_line to read more."
)
arguments_model = ReadFileArguments
requires_repository = True
[docs]
def run(self, arguments: PublicToolArguments) -> str:
"""
Read the lines of the file that the arguments name, with :func:`~smarter.apps.mcpserver.services.reading.read_file`.
The result starts with the file's GitHub url at the indexed commit, and its published
documentation page, if it is one.
**Side effects:** database queries, and, for a repository that is not indexed yet, a Celery task
queued to index it.
:param arguments: The validated arguments, a :class:`ReadFileArguments`.
:returns: The lines, in a code block, as Markdown.
:raises SmarterMCPServerToolError: If the repository has no such file. The error suggests files
with a similar path.
:raises SmarterMCPServerServiceError: If the repository is not indexed yet.
"""
read_arguments = cast(ReadFileArguments, arguments)
repository = cast(ProjectRepository, self.scope.resolve(read_arguments.repository, required=True))
try:
file_lines = read_file(repository, read_arguments.path, read_arguments.start_line, read_arguments.end_line)
except SmarterMCPServerNotFoundError as e:
raise not_found(e, "Use list_files or search_code to find it.") from e
lines = file_lines.lines
header = f"{repository.name}/{file_lines.path}, lines {lines.start}-{lines.end} of {lines.total}\n{file_lines.github_url}"
if file_lines.documentation_url:
header += f"\nPublished at {file_lines.documentation_url}"
footer = continuation("read_file", "file", lines)
language = code_fence_language(file_lines.path)
text = f"{header}\n\n```{language}\n{lines.text}\n```{footer}"
return text
# -----------------------------------------------------------------------------
# list_files
# -----------------------------------------------------------------------------
class ListFilesArguments(PublicToolArguments):
"""The arguments of list_files."""
path: Optional[str] = Field(
default=None,
max_length=512,
description="The folder, e.g. smarter/smarter/apps. Omit it for the repository's top-level folder.",
)
[docs]
class ListFilesTool(PublicTool):
"""
The ``list_files`` tool: the folders and indexed files directly in a folder of a repository.
Only indexed files are listed, so a folder of only binaries or vendored files does not appear.
"""
name = "list_files"
title = "List files"
description = (
"List the folders and indexed files in a folder of a repository of The Smarter Project, with the "
"number of files in each folder. Use it to explore a repository's layout."
)
arguments_model = ListFilesArguments
requires_repository = True
[docs]
def run(self, arguments: PublicToolArguments) -> str:
"""
List the folder that the arguments name, or the top-level folder, with :func:`~smarter.apps.mcpserver.services.reading.list_files`.
**Side effects:** database queries, and, for a repository that is not indexed yet, a Celery task
queued to index it.
:param arguments: The validated arguments, a :class:`ListFilesArguments`.
:returns: The folder's name, its GitHub url, and its entries, as Markdown.
:raises SmarterMCPServerToolError: If the folder has no indexed files.
:raises SmarterMCPServerServiceError: If the repository is not indexed yet.
"""
list_arguments = cast(ListFilesArguments, arguments)
repository = cast(ProjectRepository, self.scope.resolve(list_arguments.repository, required=True))
try:
listing = list_files(repository, list_arguments.path)
except SmarterMCPServerNotFoundError as e:
raise not_found(e, "Omit path for the top-level folder.") from e
if listing.folder:
location = f"{repository.name}/{listing.folder}"
else:
location = f"{repository.name} (top level)"
items = entry_items(listing.entries, listing.hidden)
text = "\n".join(items)
result = f"{location}\n{listing.github_url}\n\n{text}"
return result
# -----------------------------------------------------------------------------
# read_blog_post
# -----------------------------------------------------------------------------
class ReadBlogPostArguments(PublicToolArguments):
"""The arguments of read_blog_post."""
path: str = Field(
min_length=1,
max_length=512,
description="The post's path, e.g. 2026/10/26/release-smarter-api-v0-6, or its url.",
)
start_line: int = Field(default=1, ge=1, description="The first line to read.")
end_line: Optional[int] = Field(
default=None, ge=1, description=f"The last line to read. At most {MAX_READ_LINES} lines are read at once."
)
[docs]
class ReadBlogPostTool(PublicTool):
"""The ``read_blog_post`` tool: a post of the blog, or a range of its lines, as text with line numbers."""
name = "read_blog_post"
title = "Read blog post"
description = (
"Read a post of The Smarter Project's blog, blog.smarter.sh, as text with line numbers, from the "
f"paths or urls that search_blog returns. Long posts are read {MAX_READ_LINES} lines at a time: use "
"start_line and end_line to read more."
)
arguments_model = ReadBlogPostArguments
uses_repository = False
project_wide = True
[docs]
def run(self, arguments: PublicToolArguments) -> str:
"""
Read the lines of the post that the arguments name, by its path or its url, with :func:`~smarter.apps.mcpserver.services.reading.read_blog_post`.
**Side effects:** database queries, and, if the blog is not indexed yet, a Celery task queued to
index it.
:param arguments: The validated arguments, a :class:`ReadBlogPostArguments`.
:returns: The post's url, and its lines in a code block, as Markdown.
:raises SmarterMCPServerToolError: If the blog has no such post. The error suggests posts with
the same slug.
:raises SmarterMCPServerServiceError: If the blog is not indexed yet.
"""
read_arguments = cast(ReadBlogPostArguments, arguments)
try:
post = read_blog_post(read_arguments.path, read_arguments.start_line, read_arguments.end_line)
except SmarterMCPServerNotFoundError as e:
raise not_found(e, "Use search_blog to find it.") from e
lines = post.lines
header = f"{post.url}, lines {lines.start}-{lines.end} of {lines.total}"
footer = continuation("read_blog_post", "post", lines)
text = f"{header}\n\n```markdown\n{lines.text}\n```{footer}"
return text
__all__ = [
"GetRepositoryOverviewTool",
"ListFilesTool",
"ReadBlogPostTool",
"ReadFileTool",
]