Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
19 changes: 18 additions & 1 deletion src/dockstore_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"ToolPage",
"ToolSummary",
"ToolVersion",
"ToolVersionSummary",
"TrsDescriptorType",
"TrsInfo",
"Version",
Expand Down Expand Up @@ -278,6 +279,14 @@ class ToolVersion(BaseModel):
included_apps: list[str] | None = Field(default=None, description="Apps bundled with this version.")


class ToolVersionSummary(BaseModel):
"""The few fields that pick out a TRS tool version in a list, without its images or authors."""

name: str | None = Field(default=None, description="Version name; pass this as version_id to the version tools.")
meta_version: str | None = Field(default=None, description="Revision of this version's metadata.")
is_production: bool | None = Field(default=None, description="Whether the version is marked production-ready.")


class Tool(BaseModel):
"""A GA4GH TRS tool: a Dockstore tool, workflow, or other entry as the TRS API describes it."""

Expand Down Expand Up @@ -305,7 +314,15 @@ class ToolSummary(BaseModel):
default_factory=list, description="Every descriptor language any of its versions is available in."
)
version_names: list[str] = Field(
default_factory=list, description="Name of each version; pass one as version_id to the version tools."
default_factory=list,
description=(
"Names of up to 10 versions, production-ready ones first; pass one as version_id to the version tools. "
"Use list_tool_versions for the rest."
),
)
version_count: int = Field(default=0, description="How many versions the tool has in all.")
versions_truncated: bool = Field(
default=False, description="Whether version_names leaves some versions out; see version_count."
)
description: str | None = Field(default=None, description="The start of the tool's description, shortened.")

Expand Down
44 changes: 37 additions & 7 deletions src/dockstore_mcp/tools/trs.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
ToolPage,
ToolSummary,
ToolVersion,
ToolVersionSummary,
TrsDescriptorType,
TrsInfo,
)
Expand All @@ -56,6 +57,10 @@
#: How much of a tool's description a summary keeps.
SUMMARY_DESCRIPTION_LENGTH = 200

#: How many version names a summary keeps. Monorepo workflows can have a version
#: for every branch and tag of their repository, over a thousand of them.
SUMMARY_VERSION_LIMIT = 10

ToolId = Annotated[
str,
Field(description="TRS tool id, e.g. '#workflow/github.com/org/repo/name', as list_tools or search_tools give."),
Expand All @@ -67,8 +72,9 @@
bool,
Field(
description=(
"Return each tool as a short summary (id, name, languages, version names, the start of its "
"description) instead of in full. Much smaller: use it to scan or group many tools."
"Return each tool as a short summary (id, name, languages, a version count and up to "
f"{SUMMARY_VERSION_LIMIT} version names, the start of its description) instead of in full. "
"Much smaller: use it to scan or group many tools."
)
),
]
Expand All @@ -88,8 +94,12 @@ def _segment(value: str) -> str:


def _summarize(tool: Tool) -> ToolSummary:
"""Reduce ``tool`` to a :class:`ToolSummary`, shortening its description."""
"""Reduce ``tool`` to a :class:`ToolSummary`, shortening its description and version list."""
versions = tool.versions or []
# Production-ready versions first; sorted() is stable, so the rest keep Dockstore's order.
version_names = [
version.name for version in sorted(versions, key=lambda version: not version.is_production) if version.name
]
descriptor_types = sorted({language for version in versions for language in version.descriptor_type or []})
description = " ".join((tool.description or "").split()) or None
if description and len(description) > SUMMARY_DESCRIPTION_LENGTH:
Expand All @@ -100,7 +110,9 @@ def _summarize(tool: Tool) -> ToolSummary:
organization=tool.organization,
tool_class=tool.toolclass.name if tool.toolclass else None,
descriptor_types=descriptor_types,
version_names=[version.name for version in versions if version.name],
version_names=version_names[:SUMMARY_VERSION_LIMIT],
version_count=len(version_names),
versions_truncated=len(version_names) > SUMMARY_VERSION_LIMIT,
description=description,
)

Expand Down Expand Up @@ -275,18 +287,36 @@ async def get_tool(tool_id: ToolId) -> Tool:
return Tool.model_validate(await get_json(f"/tools/{_segment(tool_id)}"))

@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True})
async def list_tool_versions(tool_id: ToolId) -> list[ToolVersion]:
async def list_tool_versions(
tool_id: ToolId,
summary: Annotated[
bool,
Field(
description=(
"Return each version as just its name, meta_version, and is_production instead of in full. "
"Much smaller: use it to scan or pick from many versions, then get_tool_version for details."
)
),
] = False,
) -> list[ToolVersion] | list[ToolVersionSummary]:
"""List every version of one tool or workflow.

Each version's ``name`` is what the other version tools take as
``version_id``, and its ``descriptor_type`` lists the languages its files can
be fetched in.

A workflow in a monorepo can have a version for every branch and tag of its
repository, over a thousand of them, so ask for a ``summary`` unless you need
each version's images or authors.

Returns:
Every version of the tool.
Every version of the tool, in full or (if ``summary``) summarized.
"""
data = await get_json(f"/tools/{_segment(tool_id)}/versions")
return [ToolVersion.model_validate(item) for item in data]
versions = [ToolVersion.model_validate(item) for item in data]
if summary:
return [ToolVersionSummary.model_validate(version, from_attributes=True) for version in versions]
return versions

@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True})
async def get_tool_version(tool_id: ToolId, version_id: VersionId) -> ToolVersion:
Expand Down
23 changes: 23 additions & 0 deletions tests/test_trs.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,26 @@ async def test_list_tools_summarizes(client: Client[Any], _mock_trs_api: dict[st
assert summary["tool_class"] == "Workflow"
assert summary["descriptor_types"] == ["CWL", "WDL"]
assert summary["version_names"] == [VERSION_ID, "1.0"]
assert (summary["version_count"], summary["versions_truncated"]) == (2, False)
assert summary["description"].startswith("# Title word word")
assert len(summary["description"]) == 200
assert summary["description"].endswith("…")
assert "versions" not in summary


async def test_summary_caps_version_names(client: Client[Any], _mock_trs_api: dict[str, httpx.Response]) -> None:
versions = [{**VERSION_RESPONSE, "name": f"branch-{i}", "is_production": i == 25} for i in range(30)]
tool = {**TOOL_RESPONSE, "versions": versions}
_mock_trs_api["/tools"] = httpx.Response(200, json=[tool])

async with client:
result = await client.call_tool("list_tools", {"summary": True})
assert result.structured_content is not None
summary = result.structured_content["tools"][0]
assert summary["version_names"] == ["branch-25"] + [f"branch-{i}" for i in range(9)]
assert (summary["version_count"], summary["versions_truncated"]) == (30, True)


async def test_search_tools_summarizes(client: Client[Any]) -> None:
async with client:
result = await client.call_tool("search_tools", {"toolname": "name", "summary": True, "limit": 5})
Expand Down Expand Up @@ -332,6 +346,15 @@ async def test_list_tool_versions(client: Client[Any]) -> None:
assert [version.name for version in result.data] == [VERSION_ID]


async def test_list_tool_versions_summarizes(client: Client[Any]) -> None:
async with client:
result = await client.call_tool("list_tool_versions", {"tool_id": TOOL_ID, "summary": True})
assert result.structured_content is not None
[version] = result.structured_content["result"]
assert set(version) == {"name", "meta_version", "is_production"}
assert version["name"] == VERSION_ID


async def test_get_tool_version(client: Client[Any], requests_made: list[httpx.Request]) -> None:
async with client:
result = await client.call_tool("get_tool_version", {"tool_id": TOOL_ID, "version_id": VERSION_ID})
Expand Down