diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 14f6950..4a5e020 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -48,6 +48,7 @@ "ToolPage", "ToolSummary", "ToolVersion", + "ToolVersionSummary", "TrsDescriptorType", "TrsInfo", "Version", @@ -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.""" @@ -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.") diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 3219d84..43510f1 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -39,6 +39,7 @@ ToolPage, ToolSummary, ToolVersion, + ToolVersionSummary, TrsDescriptorType, TrsInfo, ) @@ -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."), @@ -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." ) ), ] @@ -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: @@ -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, ) @@ -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: diff --git a/tests/test_trs.py b/tests/test_trs.py index 376d146..68dbe4e 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -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}) @@ -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})