From 4fc6271b0e4f9e968718d8fd76466e3c9ebfa7f5 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Tue, 22 Sep 2026 10:43:51 -0400 Subject: [PATCH 01/21] Wire up get_trs_info and list_tool_classes against Dockstore's TRS API Implements the two unauthenticated, parameterless TRS V2 endpoints (service-info and toolClasses), plus a shared camelCase-to-snake_case casing helper for translating their responses into our models. Co-Authored-By: Claude Sonnet 5 --- README.md | 39 +++++----- pyproject.toml | 1 + src/dockstore_mcp/casing.py | 48 +++++++++++++ src/dockstore_mcp/models.py | 45 ++++++++++++ src/dockstore_mcp/tools/__init__.py | 3 +- src/dockstore_mcp/tools/trs.py | 77 ++++++++++++++++++++ tests/test_casing.py | 51 +++++++++++++ tests/test_server.py | 2 + tests/test_trs.py | 108 ++++++++++++++++++++++++++++ 9 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 src/dockstore_mcp/casing.py create mode 100644 src/dockstore_mcp/tools/trs.py create mode 100644 tests/test_casing.py create mode 100644 tests/test_trs.py diff --git a/README.md b/README.md index 76c7381..2954218 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ MCP, so it is deployed alongside the Dockstore webservice rather than inside it. It is built on [FastMCP](https://gofastmcp.com) 4 and ships as a container image. -> **Status: scaffold.** The only tool with a body is `hello`, which reports the -> configured Dockstore instance and proves the plumbing end to end. The four Dockstore -> tools are declared — names, arguments, and response shapes — but each one raises -> `NotImplementedError` until it is wired up to the Dockstore API. +> **Status: scaffold.** `hello`, `get_trs_info`, and `list_tool_classes` have working +> bodies; the other four Dockstore tools are declared — names, arguments, and response +> shapes — but each one raises `NotImplementedError` until it is wired up to the +> Dockstore API. ## Requirements @@ -124,24 +124,28 @@ and does not check PyPI for updates on startup; see the ## Tools -| Tool | Description | -| ---------------- | -------------------------------------------------------------------------------- | -| `hello` | Greets the caller and reports the Dockstore instance and server version. No I/O. | -| `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | -| `get_entry` | Retrieves the requested fields of one entry. | -| `get_version` | Retrieves the requested fields of one version of an entry. | -| `get_file` | Retrieves the requested fields of one file belonging to a version. | - -The last four are scaffolding and are not implemented yet. They are a chain: -`search_entries` yields entry identifiers, an entry yields version identifiers, and a -version yields file paths. Each lookup takes a list of fields so that a caller can ask -for a name and a date without also pulling down a README or a whole descriptor. +| Tool | Description | +| ------------------- | ------------------------------------------------------------------------------------- | +| `hello` | Greets the caller and reports the Dockstore instance and server version. No I/O. | +| `get_trs_info` | Describes this instance's GA4GH TRS API: identifiers, version, and operator. | +| `list_tool_classes` | Lists the tool classes (e.g. `Workflow`) this instance's TRS API sorts entries into. | +| `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | +| `get_entry` | Retrieves the requested fields of one entry. | +| `get_version` | Retrieves the requested fields of one version of an entry. | +| `get_file` | Retrieves the requested fields of one file belonging to a version. | + +`get_trs_info` and `list_tool_classes` call Dockstore's GA4GH TRS V2 API directly. The +last four are scaffolding and are not implemented yet. They are a chain: `search_entries` +yields entry identifiers, an entry yields version identifiers, and a version yields file +paths. Each lookup takes a list of fields so that a caller can ask for a name and a date +without also pulling down a README or a whole descriptor. ## Layout ``` src/dockstore_mcp/ ├── __main__.py command line entry point (`dockstore-mcp`) +├── casing.py camelCase JSON -> snake_case models, for API-backed tools ├── config.py settings, read from the environment ├── models.py entry, version, and file types shared by the tools ├── server.py server construction, /health route @@ -149,7 +153,8 @@ src/dockstore_mcp/ ├── __init__.py registers every tool group ├── entries.py get_entry, get_version, get_file ├── hello.py the hello tool - └── search.py search_entries + ├── search.py search_entries + └── trs.py get_trs_info, list_tool_classes tests/ pytest suite, using FastMCP's in-memory client Dockerfile two-stage build of the deployable image ``` diff --git a/pyproject.toml b/pyproject.toml index fa33712..78c7e90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ ] dependencies = [ "fastmcp>=4.0.3,<5", + "httpx2>=2.5,<3", "pydantic-settings>=2.7", # cryptography (pulled in via fastmcp -> authlib) ships arm64-only macOS wheels # from 49.0.0 on. Without this pin, Intel macs fall back to the sdist and need a diff --git a/src/dockstore_mcp/casing.py b/src/dockstore_mcp/casing.py new file mode 100644 index 0000000..523d080 --- /dev/null +++ b/src/dockstore_mcp/casing.py @@ -0,0 +1,48 @@ +# Copyright 2026 OICR and UCSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Translating the Dockstore and GA4GH APIs' camelCase JSON into this package's +snake_case models. + +Every model in :mod:`dockstore_mcp.models` spells its fields snake_case, like the +rest of the codebase, but the APIs this server calls return camelCase keys. Tool +implementations run a response through :func:`normalize_keys` before handing it to +a model's ``model_validate``, rather than hand-writing a key map per endpoint. +""" + +import re +from typing import Any + +__all__ = ["camel_to_snake", "normalize_keys"] + +#: Matches the position just before each interior capital letter, e.g. the two +#: gaps in 'toolId' -> ['tool', 'Id']. +_WORD_BOUNDARY = re.compile(r"(? str: + """Convert one camelCase key to snake_case, for example 'toolId' -> 'tool_id'.""" + return _WORD_BOUNDARY.sub("_", key).lower() + + +def normalize_keys(value: Any) -> Any: + """Recursively convert every dict key in ``value`` from camelCase to snake_case. + + Walks into nested dicts and lists so a whole API response can be normalized in + one call before validation, however deeply the objects it describes are nested. + """ + if isinstance(value, dict): + return {camel_to_snake(key): normalize_keys(val) for key, val in value.items()} + if isinstance(value, list): + return [normalize_keys(item) for item in value] + return value diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 51eba26..087b7e4 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -35,8 +35,12 @@ "EntryType", "File", "FileField", + "ServiceOrganization", + "ServiceType", "SortBy", "SortOrder", + "ToolClass", + "TrsInfo", "Version", "VersionField", ] @@ -171,6 +175,47 @@ class File(BaseModel): url: str | None = Field(default=None, description="Address the file can be fetched from.") +class ServiceType(BaseModel): + """Which GA4GH API a service implements, and at what version.""" + + group: str = Field(description="Namespace in reverse domain name format, for example 'org.ga4gh'.") + artifact: str = Field(description="Name of the API or GA4GH specification implemented, for example 'trs'.") + version: str = Field(description="Version of the API or specification implemented.") + + +class ServiceOrganization(BaseModel): + """The organization operating a GA4GH service.""" + + name: str = Field(description="Name of the organization responsible for the service.") + url: str = Field(description="URL of the organization's website.") + + +class TrsInfo(BaseModel): + """GA4GH TRS service-info: metadata describing a Dockstore instance's TRS API.""" + + id: str = Field(description="Unique identifier of this service, in reverse domain name notation.") + name: str = Field(description="Human-readable name of this service.") + type: ServiceType = Field(description="Which GA4GH API this service implements, and at what version.") + organization: ServiceOrganization = Field(description="Organization operating this service.") + version: str = Field(description="Version of the service software.") + description: str | None = Field(default=None, description="Human-readable description of the service.") + contact_url: str | None = Field(default=None, description="Contact URL or mailto link for the service.") + documentation_url: str | None = Field(default=None, description="URL of the service's documentation.") + environment: str | None = Field( + default=None, description="Deployment environment, for example 'prod' or 'staging'." + ) + created_at: datetime | None = Field(default=None, description="When the service was first deployed.") + updated_at: datetime | None = Field(default=None, description="When the service was last updated.") + + +class ToolClass(BaseModel): + """A GA4GH TRS tool class: a category of entry, such as 'Workflow' or 'CommandLineTool'.""" + + id: str | None = Field(default=None, description="Unique identifier for the class.") + name: str | None = Field(default=None, description="Short, friendly name for the class.") + description: str | None = Field(default=None, description="Longer explanation of what this class is.") + + class EntryField(StrEnum): """Fields of an :class:`Entry` that get_entry can return.""" diff --git a/src/dockstore_mcp/tools/__init__.py b/src/dockstore_mcp/tools/__init__.py index 505e9e2..ec9ca0c 100644 --- a/src/dockstore_mcp/tools/__init__.py +++ b/src/dockstore_mcp/tools/__init__.py @@ -21,7 +21,7 @@ from fastmcp import FastMCP from dockstore_mcp.config import Settings -from dockstore_mcp.tools import entries, hello, search +from dockstore_mcp.tools import entries, hello, search, trs __all__ = ["register_all"] @@ -31,3 +31,4 @@ def register_all(mcp: FastMCP, settings: Settings) -> None: hello.register(mcp, settings) search.register(mcp, settings) entries.register(mcp, settings) + trs.register(mcp, settings) diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py new file mode 100644 index 0000000..119f5a7 --- /dev/null +++ b/src/dockstore_mcp/tools/trs.py @@ -0,0 +1,77 @@ +# Copyright 2026 OICR and UCSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""GA4GH TRS service metadata: what this Dockstore instance is, and what kinds of +tools it registers. + +Unlike the tools in ``entries.py`` and ``search.py``, these two are wired up to the +real Dockstore API: both endpoints are unauthenticated, parameterless GETs against +the TRS V2 API, with small, fixed response shapes. +""" + +import logging + +import httpx2 as httpx +from fastmcp import FastMCP + +from dockstore_mcp.casing import normalize_keys +from dockstore_mcp.config import Settings +from dockstore_mcp.models import ToolClass, TrsInfo + +__all__ = ["register"] + +logger = logging.getLogger(__name__) + +#: How long to wait for the Dockstore TRS API to respond. +REQUEST_TIMEOUT = 30.0 + + +def register(mcp: FastMCP, settings: Settings) -> None: + """Add the TRS service-info and tool-class tools to ``mcp``.""" + + # Shared for every call this server handles, so the tools below don't pay a + # fresh TCP/TLS handshake to Dockstore on every invocation. Reuse this same + # client as more TRS-backed tools join this module. + client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_trs_info() -> TrsInfo: + """Describe this Dockstore instance's GA4GH Tool Registry Service (TRS) API. + + Use this to identify which Dockstore instance a server is talking to, which + version of the TRS API it implements, and who operates it. It takes no + arguments and always describes the ``dockstore_url`` this server is + configured with. + + Returns: + Service metadata: identifiers, the TRS API version implemented, and the + organization operating the service. + """ + response = await client.get(f"{settings.trs_url}/service-info") + response.raise_for_status() + return TrsInfo.model_validate(normalize_keys(response.json())) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def list_tool_classes() -> list[ToolClass]: + """List the tool classes this Dockstore instance's TRS API sorts entries into. + + A tool class (for example 'Workflow' or 'CommandLineTool') is the category + Dockstore assigns an entry under the GA4GH TRS API. Reach for this to see + which classes exist, for example before filtering a TRS-level lookup by one. + + Returns: + Every tool class the service recognizes. + """ + response = await client.get(f"{settings.trs_url}/toolClasses") + response.raise_for_status() + return [ToolClass.model_validate(item) for item in normalize_keys(response.json())] diff --git a/tests/test_casing.py b/tests/test_casing.py new file mode 100644 index 0000000..3607b09 --- /dev/null +++ b/tests/test_casing.py @@ -0,0 +1,51 @@ +# Copyright 2026 OICR and UCSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the camelCase-to-snake_case helpers shared by the API-backed tools.""" + +import pytest + +from dockstore_mcp.casing import camel_to_snake, normalize_keys + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + ("id", "id"), + ("toolId", "tool_id"), + ("contactUrl", "contact_url"), + ("descriptorType", "descriptor_type"), + ("already_snake", "already_snake"), + ], +) +def test_camel_to_snake(key: str, expected: str) -> None: + assert camel_to_snake(key) == expected + + +def test_normalize_keys_walks_nested_dicts_and_lists() -> None: + payload = { + "toolId": "abc", + "organization": {"contactUrl": "mailto:a@b.com"}, + "versions": [{"toolVersionId": "1"}, {"toolVersionId": "2"}], + } + assert normalize_keys(payload) == { + "tool_id": "abc", + "organization": {"contact_url": "mailto:a@b.com"}, + "versions": [{"tool_version_id": "1"}, {"tool_version_id": "2"}], + } + + +def test_normalize_keys_leaves_non_dict_values_alone() -> None: + assert normalize_keys("plain string") == "plain string" + assert normalize_keys(42) == 42 + assert normalize_keys(None) is None diff --git a/tests/test_server.py b/tests/test_server.py index 9a8fd5e..3500946 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -27,8 +27,10 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: assert sorted(tool.name for tool in tools) == [ "get_entry", "get_file", + "get_trs_info", "get_version", "hello", + "list_tool_classes", "search_entries", ] diff --git a/tests/test_trs.py b/tests/test_trs.py new file mode 100644 index 0000000..50ff895 --- /dev/null +++ b/tests/test_trs.py @@ -0,0 +1,108 @@ +# Copyright 2026 OICR and UCSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for get_trs_info and list_tool_classes. + +Unlike the other Dockstore tools, these two are wired up to the real API, so +instead of asserting ``NotImplementedError`` these tests stub the HTTP layer with +an ``httpx2.MockTransport`` and check that a response is parsed correctly. +""" + +from typing import Any + +import httpx2 as httpx +import pytest +from fastmcp import Client +from fastmcp.exceptions import ToolError + +SERVICE_INFO_RESPONSE = { + "id": "org.dockstore.staging", + "name": "Dockstore", + "type": {"group": "org.ga4gh", "artifact": "trs", "version": "2.0.1"}, + "organization": {"name": "Dockstore", "url": "https://dockstore.org"}, + "version": "1.21.0", + "description": "A tool and workflow registry.", + "contactUrl": "mailto:support@dockstore.org", +} + +TOOL_CLASSES_RESPONSE = [ + {"id": "CommandLineTool", "name": "CommandLineTool", "description": "A single command line tool."}, + {"id": "Workflow", "name": "Workflow", "description": "An ordered set of steps."}, +] + +#: Canned responses, keyed by path relative to the TRS API root. +RESPONSES = { + "/service-info": SERVICE_INFO_RESPONSE, + "/toolClasses": TOOL_CLASSES_RESPONSE, +} + +#: The real class, captured before any test monkeypatches ``httpx.AsyncClient``. +_RealAsyncClient = httpx.AsyncClient + + +def _mock_client_factory(handler: Any) -> Any: + """Build a stand-in for ``httpx.AsyncClient`` that routes every request through ``handler``.""" + + def fake_client(*, timeout: float) -> httpx.AsyncClient: + return _RealAsyncClient(timeout=timeout, transport=httpx.MockTransport(handler)) + + return fake_client + + +@pytest.fixture(autouse=True) +def _mock_trs_api(monkeypatch: pytest.MonkeyPatch) -> dict[str, httpx.Response]: + """Route every request trs.py makes through a canned handler instead of the network. + + trs.py now builds its ``httpx.AsyncClient`` once, when the server is constructed, + so the class can only be swapped before that happens (i.e. from this fixture, not + from within a test body). A test that wants a different response overrides it here + instead, since the handler consults ``overrides`` fresh on every request. + """ + overrides: dict[str, httpx.Response] = {} + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path.removeprefix("/api/ga4gh/trs/v2") + if path in overrides: + return overrides[path] + if path not in RESPONSES: + return httpx.Response(404, json={"error": "not found"}) + return httpx.Response(200, json=RESPONSES[path]) + + monkeypatch.setattr(httpx, "AsyncClient", _mock_client_factory(handler)) + return overrides + + +async def test_get_trs_info(client: Client[Any]) -> None: + async with client: + result = await client.call_tool("get_trs_info", {}) + assert result.data.id == "org.dockstore.staging" + assert result.data.name == "Dockstore" + assert result.data.type.artifact == "trs" + assert result.data.type.group == "org.ga4gh" + assert result.data.organization.name == "Dockstore" + assert result.data.contact_url == "mailto:support@dockstore.org" + + +async def test_list_tool_classes(client: Client[Any]) -> None: + async with client: + result = await client.call_tool("list_tool_classes", {}) + assert [tool_class.id for tool_class in result.data] == ["CommandLineTool", "Workflow"] + assert result.data[1].name == "Workflow" + + +async def test_get_trs_info_surfaces_http_errors(client: Client[Any], _mock_trs_api: dict[str, httpx.Response]) -> None: + _mock_trs_api["/service-info"] = httpx.Response(500) + + async with client: + with pytest.raises(ToolError): + await client.call_tool("get_trs_info", {}) From 3551d54175b66dce951879d09970b2c33b53c1b9 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Tue, 22 Sep 2026 13:44:41 -0400 Subject: [PATCH 02/21] Copy push-confirmation and minimal-diff PR guidance from dockstore/dockstore Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index eaaaff6..1ffdc8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,13 @@ they exercise real tool dispatch without a socket or subprocess. When creating a PR, always create it in draft mode. A human developer must be the one to mark it ready for review/move it out of draft state — Claude Code should not do this itself. +Always check with the user before pushing changes to GitHub, even to a branch/PR already being worked on in +the conversation — a push can kick off a long CI build or interrupt one that's already running. + +When a GitHub MCP server or `gh` is available, diff the current work against `develop` (or whatever branch the +PR targets) and try to minimize stylistic or otherwise-minor changes that inflate the diff and make it harder +to review, unless those changes fix something a Codacy finding or other code-quality check actually flagged. + Keep the freeform "Description" and "Review Instructions" sections brief — one paragraph each, or two for a genuinely complicated fix, not multi-paragraph writeups. (The "Security and Privacy" checklist section is separate and must still be copied verbatim per the section below.) From 0c8876aaa97feac41150d177f249a88f92a80bcb Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Thu, 24 Sep 2026 14:10:35 -0400 Subject: [PATCH 03/21] Potential fix for pull request finding 'Unused global variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/dockstore_mcp/tools/trs.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 119f5a7..3a09a22 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -19,8 +19,6 @@ the TRS V2 API, with small, fixed response shapes. """ -import logging - import httpx2 as httpx from fastmcp import FastMCP @@ -30,8 +28,6 @@ __all__ = ["register"] -logger = logging.getLogger(__name__) - #: How long to wait for the Dockstore TRS API to respond. REQUEST_TIMEOUT = 30.0 From 2e2fc588915a28bcc396b82ac2a68882e1a4cf60 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Wed, 23 Sep 2026 16:49:00 -0400 Subject: [PATCH 04/21] Add the remaining GA4GH TRS V2 tool, version, and file tools Adds list_tools, search_tools, get_tool, list_tool_versions, get_tool_version, get_tool_descriptor, get_tool_descriptor_by_path, get_tool_files, get_tool_tests, and get_tool_containerfile to trs.py, backed by Dockstore's TRS V2 endpoints, along with the TRS models they return. Co-Authored-By: Claude Opus 5.5 --- README.md | 54 +++++--- src/dockstore_mcp/models.py | 96 +++++++++++++ src/dockstore_mcp/tools/trs.py | 238 ++++++++++++++++++++++++++++++++- tests/test_server.py | 10 ++ tests/test_trs.py | 210 ++++++++++++++++++++++++++++- 5 files changed, 576 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2954218..4bf0baa 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ MCP, so it is deployed alongside the Dockstore webservice rather than inside it. It is built on [FastMCP](https://gofastmcp.com) 4 and ships as a container image. -> **Status: scaffold.** `hello`, `get_trs_info`, and `list_tool_classes` have working -> bodies; the other four Dockstore tools are declared — names, arguments, and response -> shapes — but each one raises `NotImplementedError` until it is wired up to the -> Dockstore API. +> **Status: scaffold.** `hello` and the GA4GH TRS tools (`get_trs_info` through +> `get_tool_containerfile`) have working bodies; the other four Dockstore tools are +> declared — names, arguments, and response shapes — but each one raises +> `NotImplementedError` until it is wired up to the Dockstore API. ## Requirements @@ -124,21 +124,35 @@ and does not check PyPI for updates on startup; see the ## Tools -| Tool | Description | -| ------------------- | ------------------------------------------------------------------------------------- | -| `hello` | Greets the caller and reports the Dockstore instance and server version. No I/O. | -| `get_trs_info` | Describes this instance's GA4GH TRS API: identifiers, version, and operator. | -| `list_tool_classes` | Lists the tool classes (e.g. `Workflow`) this instance's TRS API sorts entries into. | -| `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | -| `get_entry` | Retrieves the requested fields of one entry. | -| `get_version` | Retrieves the requested fields of one version of an entry. | -| `get_file` | Retrieves the requested fields of one file belonging to a version. | - -`get_trs_info` and `list_tool_classes` call Dockstore's GA4GH TRS V2 API directly. The -last four are scaffolding and are not implemented yet. They are a chain: `search_entries` -yields entry identifiers, an entry yields version identifiers, and a version yields file -paths. Each lookup takes a list of fields so that a caller can ask for a name and a date -without also pulling down a README or a whole descriptor. +| Tool | Description | +| ----------------------------- | ------------------------------------------------------------------------------------ | +| `hello` | Greets the caller and reports the Dockstore instance and server version. No I/O. | +| `get_trs_info` | Describes this instance's GA4GH TRS API: identifiers, version, and operator. | +| `list_tool_classes` | Lists the tool classes (e.g. `Workflow`) this instance's TRS API sorts entries into. | +| `list_tools` | Lists one page of every tool and workflow the TRS API serves. | +| `search_tools` | Finds TRS tools by name, organization, author, class, descriptor language, etc. | +| `get_tool` | Retrieves one TRS tool by id, including all of its versions. | +| `list_tool_versions` | Lists every version of one TRS tool. | +| `get_tool_version` | Retrieves one version of a TRS tool: authors, images, descriptor languages. | +| `get_tool_descriptor` | Fetches the primary descriptor (CWL, WDL, etc.) of a version. | +| `get_tool_descriptor_by_path` | Fetches one of a version's files by its relative path. | +| `get_tool_files` | Lists every file of a version, without content. | +| `get_tool_tests` | Fetches a version's test parameter files. | +| `get_tool_containerfile` | Fetches the containerfile (e.g. Dockerfile) that builds a version's image. | +| `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | +| `get_entry` | Retrieves the requested fields of one entry. | +| `get_version` | Retrieves the requested fields of one version of an entry. | +| `get_file` | Retrieves the requested fields of one file belonging to a version. | + +The TRS tools, from `get_trs_info` to `get_tool_containerfile`, call Dockstore's GA4GH +TRS V2 API directly. They form a chain: `list_tools` and `search_tools` yield tool ids, a +tool yields version names, and a version's `get_tool_files` yields the paths that +`get_tool_descriptor_by_path` takes. + +The last four are scaffolding and are not implemented yet. They are a chain too: +`search_entries` yields entry identifiers, an entry yields version identifiers, and a +version yields file paths. Each lookup takes a list of fields so that a caller can ask +for a name and a date without also pulling down a README or a whole descriptor. ## Layout @@ -154,7 +168,7 @@ src/dockstore_mcp/ ├── entries.py get_entry, get_version, get_file ├── hello.py the hello tool ├── search.py search_entries - └── trs.py get_trs_info, list_tool_classes + └── trs.py get_trs_info, list_tool_classes, and the other GA4GH TRS tools tests/ pytest suite, using FastMCP's in-memory client Dockerfile two-stage build of the deployable image ``` diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 087b7e4..c02c095 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -28,6 +28,7 @@ from pydantic import BaseModel, Field __all__ = [ + "Checksum", "DescriptorLanguage", "Entry", "EntryField", @@ -35,11 +36,17 @@ "EntryType", "File", "FileField", + "FileWrapper", + "ImageData", "ServiceOrganization", "ServiceType", "SortBy", "SortOrder", + "Tool", "ToolClass", + "ToolFile", + "ToolVersion", + "TrsDescriptorType", "TrsInfo", "Version", "VersionField", @@ -216,6 +223,95 @@ class ToolClass(BaseModel): description: str | None = Field(default=None, description="Longer explanation of what this class is.") +class TrsDescriptorType(StrEnum): + """The descriptor languages the GA4GH TRS API addresses files by, spelled as its URLs expect.""" + + CWL = "CWL" + WDL = "WDL" + NEXTFLOW = "NFL" + GALAXY = "GALAXY" + SNAKEMAKE = "SMK" + + +class Checksum(BaseModel): + """A checksum of a file or container image.""" + + checksum: str | None = Field(default=None, description="The hex-encoded checksum value.") + type: str | None = Field(default=None, description="Hash algorithm used, for example 'sha-256'.") + + +class ImageData(BaseModel): + """A container image a TRS tool version runs in.""" + + registry_host: str | None = Field(default=None, description="Registry hosting the image, e.g. 'quay.io'.") + image_name: str | None = Field(default=None, description="Name of the image, including its registry and tag.") + size: int | None = Field(default=None, description="Size of the image in bytes.") + updated: str | None = Field(default=None, description="When the image was last updated.") + checksum: list[Checksum] | None = Field(default=None, description="Checksums of the image.") + image_type: str | None = Field(default=None, description="Container technology, for example 'Docker'.") + + +class ToolVersion(BaseModel): + """One version of a GA4GH TRS tool, for example a Git branch or tag.""" + + id: str | None = Field(default=None, description="TRS identifier of this version, ':'.") + name: str | None = Field(default=None, description="Version name; pass this as version_id to the version tools.") + url: str | None = Field(default=None, description="TRS API URL of this version.") + author: list[str] | None = Field(default=None, description="Authors of this version.") + is_production: bool | None = Field(default=None, description="Whether the version is marked production-ready.") + images: list[ImageData] | None = Field(default=None, description="Container images this version runs in.") + descriptor_type: list[str] | None = Field( + default=None, description="Descriptor languages this version is available in, for example ['CWL']." + ) + descriptor_type_version: dict[str, list[str]] | None = Field( + default=None, description="Language versions used, keyed by descriptor type, e.g. {'WDL': ['1.0']}." + ) + containerfile: bool | None = Field(default=None, description="Whether a containerfile (e.g. Dockerfile) exists.") + meta_version: str | None = Field(default=None, description="Revision of this version's metadata.") + verified: bool | None = Field(default=None, description="Whether this version has been verified.") + verified_source: list[str] | None = Field(default=None, description="Who or what verified this version.") + signed: bool | None = Field(default=None, description="Whether this version is signed.") + included_apps: list[str] | None = Field(default=None, description="Apps bundled with this version.") + + +class Tool(BaseModel): + """A GA4GH TRS tool: a Dockstore tool, workflow, or other entry as the TRS API describes it.""" + + id: str | None = Field(default=None, description="TRS identifier of the tool; pass this as tool_id.") + url: str | None = Field(default=None, description="TRS API URL of the tool.") + aliases: list[str] | None = Field(default=None, description="Other identifiers the tool is known by.") + organization: str | None = Field(default=None, description="Organization that published the tool.") + name: str | None = Field(default=None, description="Name of the tool.") + toolclass: ToolClass | None = Field(default=None, description="Category of the tool, e.g. 'Workflow'.") + description: str | None = Field(default=None, description="Description of the tool, usually its README.") + meta_version: str | None = Field(default=None, description="Revision of this tool's metadata.") + has_checker: bool | None = Field(default=None, description="Whether the tool has a checker workflow.") + checker_url: str | None = Field(default=None, description="TRS URL of the checker workflow, if any.") + versions: list[ToolVersion] | None = Field(default=None, description="Every version of the tool.") + + +class FileWrapper(BaseModel): + """The content of one file from a TRS tool version: a descriptor, test parameter file, or containerfile.""" + + content: str | None = Field(default=None, description="The file's full text.") + checksum: list[Checksum] | None = Field(default=None, description="Checksums of the file.") + url: str | None = Field(default=None, description="Where the raw file can be fetched from.") + + +class ToolFile(BaseModel): + """One entry in the file listing of a TRS tool version.""" + + path: str | None = Field( + default=None, + description="Path relative to the primary descriptor; pass this to get_tool_descriptor_by_path.", + ) + file_type: str | None = Field( + default=None, + description="One of TEST_FILE, PRIMARY_DESCRIPTOR, SECONDARY_DESCRIPTOR, CONTAINERFILE, or OTHER.", + ) + checksum: Checksum | None = Field(default=None, description="Checksum of the file.") + + class EntryField(StrEnum): """Fields of an :class:`Entry` that get_entry can return.""" diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 3a09a22..37d299a 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -11,35 +11,90 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""GA4GH TRS service metadata: what this Dockstore instance is, and what kinds of -tools it registers. +"""GA4GH TRS V2 tools: what this Dockstore instance is, what kinds of tools it +registers, and the tools, versions, and files themselves. -Unlike the tools in ``entries.py`` and ``search.py``, these two are wired up to the -real Dockstore API: both endpoints are unauthenticated, parameterless GETs against -the TRS V2 API, with small, fixed response shapes. +Unlike the tools in ``entries.py`` and ``search.py``, these are wired up to the real +Dockstore API: every one is an unauthenticated GET against the TRS V2 API. + +The lookups form a chain: ``list_tools``/``search_tools`` yield tool ids, +``get_tool``/``list_tool_versions`` yield version names, and a version's +``get_tool_files`` listing yields the paths ``get_tool_descriptor_by_path`` takes. """ +from typing import Annotated, Any +from urllib.parse import quote + import httpx2 as httpx from fastmcp import FastMCP +from pydantic import Field from dockstore_mcp.casing import normalize_keys from dockstore_mcp.config import Settings -from dockstore_mcp.models import ToolClass, TrsInfo +from dockstore_mcp.models import ( + FileWrapper, + Tool, + ToolClass, + ToolFile, + ToolVersion, + TrsDescriptorType, + TrsInfo, +) __all__ = ["register"] #: How long to wait for the Dockstore TRS API to respond. REQUEST_TIMEOUT = 30.0 +#: How many tools list_tools and search_tools return per page unless asked for more. +#: The TRS default of 1000 would flood a model's context: every tool carries all of +#: its versions. +DEFAULT_PAGE_SIZE = 20 + +ToolId = Annotated[ + str, + Field(description="TRS tool id, e.g. '#workflow/github.com/org/repo/name', as list_tools or search_tools give."), +] +VersionId = Annotated[str, Field(description="Version name, e.g. 'master' or '1.0', as list_tool_versions gives.")] +DescriptorType = Annotated[TrsDescriptorType, Field(description="Descriptor language of the files to fetch.")] +Limit = Annotated[int, Field(ge=1, le=1000, description="Most tools to return in this page.")] +Offset = Annotated[ + int, + Field(ge=0, description="Which page to return, counting from 0: Dockstore treats offset as a page number."), +] + + +def _segment(value: str) -> str: + """Percent-encode ``value`` as one URL path segment. + + TRS ids, version names, and relative paths all routinely contain '/' (and ids + a leading '#'), which Dockstore only accepts fully encoded. + """ + return quote(value, safe="") + def register(mcp: FastMCP, settings: Settings) -> None: - """Add the TRS service-info and tool-class tools to ``mcp``.""" + """Add the TRS V2 tools to ``mcp``.""" # Shared for every call this server handles, so the tools below don't pay a # fresh TCP/TLS handshake to Dockstore on every invocation. Reuse this same # client as more TRS-backed tools join this module. client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT) + async def get_json(path: str, params: dict[str, Any] | None = None) -> Any: + """GET ``path`` under the TRS API root and return its parsed body. + + Unlike service-info, the TRS ``/tools`` responses already use snake_case + keys, so they skip ``normalize_keys``, which would also mangle data-valued + keys such as the 'CWL' in ``descriptor_type_version``. + """ + response = await client.get(f"{settings.trs_url}{path}", params=params) + response.raise_for_status() + return response.json() + + def version_path(tool_id: str, version_id: str) -> str: + return f"/tools/{_segment(tool_id)}/versions/{_segment(version_id)}" + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_trs_info() -> TrsInfo: """Describe this Dockstore instance's GA4GH Tool Registry Service (TRS) API. @@ -71,3 +126,172 @@ async def list_tool_classes() -> list[ToolClass]: response = await client.get(f"{settings.trs_url}/toolClasses") response.raise_for_status() return [ToolClass.model_validate(item) for item in normalize_keys(response.json())] + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0) -> list[Tool]: + """List one page of every tool and workflow this Dockstore instance's TRS API serves. + + Reach for search_tools instead to narrow the list by name, language, class, + or other filters; this one pages through everything. Ask for the next page by + incrementing ``offset`` by one (it is a page number, not an item index); a + page shorter than ``limit`` is the last one. + + Returns: + Up to ``limit`` tools, each with all of its versions. + """ + data = await get_json("/tools", {"limit": limit, "offset": offset}) + return [Tool.model_validate(item) for item in data] + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def search_tools( + name: Annotated[str | None, Field(description="Match against the tool's repository path, e.g. 'gatk'.")] = None, + toolname: Annotated[str | None, Field(description="Match against the tool or workflow's own name.")] = None, + description: Annotated[str | None, Field(description="Match against the tool's description.")] = None, + organization: Annotated[ + str | None, Field(description="Match against the publishing organization, e.g. 'broadinstitute'.") + ] = None, + author: Annotated[str | None, Field(description="Match against the tool's author.")] = None, + registry: Annotated[ + str | None, Field(description="Match against the image or source registry, e.g. 'quay.io'.") + ] = None, + alias: Annotated[str | None, Field(description="Match against one of the tool's aliases.")] = None, + tool_class: Annotated[ + str | None, Field(description="Only tools of this class, e.g. 'Workflow'; see list_tool_classes.") + ] = None, + descriptor_type: Annotated[ + TrsDescriptorType | None, Field(description="Only tools available in this descriptor language.") + ] = None, + checker: Annotated[ + bool | None, Field(description="True for only checker workflows, False to exclude them.") + ] = None, + limit: Limit = DEFAULT_PAGE_SIZE, + offset: Offset = 0, + ) -> list[Tool]: + """Find tools and workflows through the TRS API by name, language, class, and other filters. + + Every filter given must match; text filters match substrings. Page through + the results as with list_tools. For richer keyword search with facets, the + Dockstore Search page equivalent is search_entries. + + Returns: + Up to ``limit`` matching tools, each with all of its versions. + """ + filters = { + "name": name, + "toolname": toolname, + "description": description, + "organization": organization, + "author": author, + "registry": registry, + "alias": alias, + "toolClass": tool_class, + "descriptorType": descriptor_type, + "checker": None if checker is None else str(checker).lower(), + } + params = {key: value for key, value in filters.items() if value is not None} + data = await get_json("/tools", {**params, "limit": limit, "offset": offset}) + return [Tool.model_validate(item) for item in data] + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool(tool_id: ToolId) -> Tool: + """Retrieve one tool or workflow by its TRS id, including every one of its versions. + + Returns: + The tool's metadata and its full list of versions. + """ + 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]: + """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. + + Returns: + Every version of the tool. + """ + data = await get_json(f"/tools/{_segment(tool_id)}/versions") + return [ToolVersion.model_validate(item) for item in data] + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool_version(tool_id: ToolId, version_id: VersionId) -> ToolVersion: + """Retrieve one version of a tool or workflow: its authors, container images, and languages. + + Returns: + The version's metadata. + """ + return ToolVersion.model_validate(await get_json(version_path(tool_id, version_id))) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool_descriptor( + tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType + ) -> FileWrapper: + """Fetch the primary descriptor of one version: the main CWL, WDL, Nextflow, etc. file. + + Reach for get_tool_files to see what other files the version has, and + get_tool_descriptor_by_path to fetch one of them. + + Returns: + The descriptor's content, checksum, and source URL. + """ + path = f"{version_path(tool_id, version_id)}/{descriptor_type}/descriptor" + return FileWrapper.model_validate(await get_json(path)) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool_descriptor_by_path( + tool_id: ToolId, + version_id: VersionId, + descriptor_type: DescriptorType, + relative_path: Annotated[ + str, + Field(description="Path of the file relative to the primary descriptor, as get_tool_files gives."), + ], + ) -> FileWrapper: + """Fetch one file of a version by path: an imported descriptor, a config file, and so on. + + Returns: + The file's content, checksum, and source URL. + """ + path = f"{version_path(tool_id, version_id)}/{descriptor_type}/descriptor/{_segment(relative_path)}" + return FileWrapper.model_validate(await get_json(path)) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool_files(tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType) -> list[ToolFile]: + """List every file of one version, without their content. + + Use this to find a version's secondary descriptors, test parameter files, and + containerfile before fetching one. + + Returns: + Each file's path and type (primary or secondary descriptor, test file, etc.). + """ + data = await get_json(f"{version_path(tool_id, version_id)}/{descriptor_type}/files") + return [ToolFile.model_validate(item) for item in data] + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool_tests( + tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType + ) -> list[FileWrapper]: + """Fetch the test parameter files of one version: example inputs for running it. + + Returns: + The content of every test parameter file; empty if the version has none. + """ + data = await get_json(f"{version_path(tool_id, version_id)}/{descriptor_type}/tests") + return [FileWrapper.model_validate(item) for item in data] + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool_containerfile(tool_id: ToolId, version_id: VersionId) -> list[FileWrapper]: + """Fetch the containerfile (e.g. Dockerfile) that builds one version's image. + + Only some tools have one, typically those registered from a Docker image + rather than as a workflow; a version whose ``containerfile`` is false has + none, and the call fails. + + Returns: + The content of each containerfile. + """ + data = await get_json(f"{version_path(tool_id, version_id)}/containerfile") + return [FileWrapper.model_validate(item) for item in data] diff --git a/tests/test_server.py b/tests/test_server.py index 3500946..3993e03 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -27,11 +27,21 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: assert sorted(tool.name for tool in tools) == [ "get_entry", "get_file", + "get_tool", + "get_tool_containerfile", + "get_tool_descriptor", + "get_tool_descriptor_by_path", + "get_tool_files", + "get_tool_tests", + "get_tool_version", "get_trs_info", "get_version", "hello", "list_tool_classes", + "list_tool_versions", + "list_tools", "search_entries", + "search_tools", ] diff --git a/tests/test_trs.py b/tests/test_trs.py index 50ff895..5d05d9f 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -11,9 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for get_trs_info and list_tool_classes. +"""Tests for the GA4GH TRS V2 tools in trs.py. -Unlike the other Dockstore tools, these two are wired up to the real API, so +Unlike the other Dockstore tools, these are wired up to the real API, so instead of asserting ``NotImplementedError`` these tests stub the HTTP layer with an ``httpx2.MockTransport`` and check that a response is parsed correctly. """ @@ -40,10 +40,86 @@ {"id": "Workflow", "name": "Workflow", "description": "An ordered set of steps."}, ] -#: Canned responses, keyed by path relative to the TRS API root. -RESPONSES = { +TOOL_ID = "#workflow/github.com/org/repo/name" +VERSION_ID = "feature/branch" + +VERSION_RESPONSE = { + "id": f"{TOOL_ID}:{VERSION_ID}", + "name": VERSION_ID, + "url": "https://staging.dockstore.org/api/ga4gh/trs/v2/tools/x/versions/y", + "author": ["Jane Doe"], + "is_production": False, + "images": [ + { + "checksum": [{"checksum": "abc123", "type": "sha-256"}], + "image_name": "quay.io/org/image:1.0", + "image_type": "Docker", + "registry_host": "quay.io", + "size": 1024, + "updated": "2026-09-23T20:42:58Z", + } + ], + "descriptor_type": ["CWL"], + "descriptor_type_version": {"CWL": ["v1.0"]}, + "containerfile": False, + "meta_version": "Thu Jan 01 00:00:00 UTC 1970", + "verified": False, + "verified_source": [], + "signed": False, + "included_apps": [], +} + +TOOL_RESPONSE = { + "id": TOOL_ID, + "url": "https://staging.dockstore.org/api/ga4gh/trs/v2/tools/x", + "aliases": [], + "organization": "org", + "name": "repo/name", + "toolclass": {"id": "1", "name": "Workflow", "description": "Workflow"}, + "description": "Does a thing.", + "meta_version": "2026-01-01 00:00:00.0", + "has_checker": False, + "checker_url": "", + "versions": [VERSION_RESPONSE], +} + +DESCRIPTOR_RESPONSE = { + "checksum": [{"checksum": "def456", "type": "sha-256"}], + "content": "cwlVersion: v1.0\nclass: Workflow\n", + "image_type": {}, + "url": "https://raw.githubusercontent.com/org/repo/feature/branch/main.cwl", +} + +SECONDARY_PATH = "../tools/step.cwl" + +FILES_RESPONSE = [ + {"checksum": {"checksum": "def456", "type": "sha-256"}, "file_type": "PRIMARY_DESCRIPTOR", "path": "main.cwl"}, + { + "checksum": {"checksum": "789abc", "type": "sha-256"}, + "file_type": "SECONDARY_DESCRIPTOR", + "path": SECONDARY_PATH, + }, +] + +TESTS_RESPONSE = [{"checksum": [], "content": '{"input": 1}', "url": "https://example.org/test.json"}] + +CONTAINERFILE_RESPONSE = [{"checksum": [], "content": "FROM ubuntu:24.04\n", "url": "https://example.org/Dockerfile"}] + +_VERSION_PATH = f"/tools/{TOOL_ID}/versions/{VERSION_ID}" + +#: Canned responses, keyed by (decoded) path relative to the TRS API root. +RESPONSES: dict[str, Any] = { "/service-info": SERVICE_INFO_RESPONSE, "/toolClasses": TOOL_CLASSES_RESPONSE, + "/tools": [TOOL_RESPONSE], + f"/tools/{TOOL_ID}": TOOL_RESPONSE, + f"/tools/{TOOL_ID}/versions": [VERSION_RESPONSE], + _VERSION_PATH: VERSION_RESPONSE, + f"{_VERSION_PATH}/CWL/descriptor": DESCRIPTOR_RESPONSE, + f"{_VERSION_PATH}/CWL/descriptor/{SECONDARY_PATH}": DESCRIPTOR_RESPONSE, + f"{_VERSION_PATH}/CWL/files": FILES_RESPONSE, + f"{_VERSION_PATH}/CWL/tests": TESTS_RESPONSE, + f"{_VERSION_PATH}/containerfile": CONTAINERFILE_RESPONSE, } #: The real class, captured before any test monkeypatches ``httpx.AsyncClient``. @@ -59,8 +135,14 @@ def fake_client(*, timeout: float) -> httpx.AsyncClient: return fake_client +@pytest.fixture +def requests_made() -> list[httpx.Request]: + """Every request trs.py sends during a test, in order.""" + return [] + + @pytest.fixture(autouse=True) -def _mock_trs_api(monkeypatch: pytest.MonkeyPatch) -> dict[str, httpx.Response]: +def _mock_trs_api(monkeypatch: pytest.MonkeyPatch, requests_made: list[httpx.Request]) -> dict[str, httpx.Response]: """Route every request trs.py makes through a canned handler instead of the network. trs.py now builds its ``httpx.AsyncClient`` once, when the server is constructed, @@ -71,6 +153,7 @@ def _mock_trs_api(monkeypatch: pytest.MonkeyPatch) -> dict[str, httpx.Response]: overrides: dict[str, httpx.Response] = {} def handler(request: httpx.Request) -> httpx.Response: + requests_made.append(request) path = request.url.path.removeprefix("/api/ga4gh/trs/v2") if path in overrides: return overrides[path] @@ -106,3 +189,120 @@ async def test_get_trs_info_surfaces_http_errors(client: Client[Any], _mock_trs_ async with client: with pytest.raises(ToolError): await client.call_tool("get_trs_info", {}) + + +async def test_list_tools_pages(client: Client[Any], requests_made: list[httpx.Request]) -> None: + async with client: + result = await client.call_tool("list_tools", {"limit": 5, "offset": 2}) + assert [tool.id for tool in result.data] == [TOOL_ID] + assert result.data[0].toolclass.name == "Workflow" + assert result.data[0].versions[0].name == VERSION_ID + assert dict(requests_made[0].url.params) == {"limit": "5", "offset": "2"} + + +async def test_list_tools_defaults_to_a_small_page(client: Client[Any], requests_made: list[httpx.Request]) -> None: + async with client: + await client.call_tool("list_tools", {}) + assert dict(requests_made[0].url.params) == {"limit": "20", "offset": "0"} + + +async def test_search_tools_sends_only_given_filters(client: Client[Any], requests_made: list[httpx.Request]) -> None: + async with client: + result = await client.call_tool( + "search_tools", + {"toolname": "name", "tool_class": "Workflow", "descriptor_type": "NFL", "checker": False}, + ) + assert [tool.id for tool in result.data] == [TOOL_ID] + assert dict(requests_made[0].url.params) == { + "toolname": "name", + "toolClass": "Workflow", + "descriptorType": "NFL", + "checker": "false", + "limit": "20", + "offset": "0", + } + + +async def test_get_tool_encodes_the_id(client: Client[Any], requests_made: list[httpx.Request]) -> None: + async with client: + result = await client.call_tool("get_tool", {"tool_id": TOOL_ID}) + assert result.data.id == TOOL_ID + assert result.data.organization == "org" + assert requests_made[0].url.raw_path.endswith(b"/tools/%23workflow%2Fgithub.com%2Forg%2Frepo%2Fname") + + +async def test_list_tool_versions(client: Client[Any]) -> None: + async with client: + result = await client.call_tool("list_tool_versions", {"tool_id": TOOL_ID}) + assert [version.name for version in result.data] == [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}) + assert result.data.author == ["Jane Doe"] + assert result.data.images[0].registry_host == "quay.io" + assert result.data.images[0].checksum[0].type == "sha-256" + assert result.data.descriptor_type_version == {"CWL": ["v1.0"]} + assert requests_made[0].url.raw_path.endswith(b"/versions/feature%2Fbranch") + + +async def test_get_tool_descriptor(client: Client[Any]) -> None: + async with client: + result = await client.call_tool( + "get_tool_descriptor", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL"} + ) + assert result.data.content.startswith("cwlVersion: v1.0") + assert result.data.checksum[0].checksum == "def456" + + +async def test_get_tool_descriptor_by_path_encodes_the_path( + client: Client[Any], requests_made: list[httpx.Request] +) -> None: + async with client: + result = await client.call_tool( + "get_tool_descriptor_by_path", + {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL", "relative_path": SECONDARY_PATH}, + ) + assert result.data.content.startswith("cwlVersion") + assert requests_made[0].url.raw_path.endswith(b"/CWL/descriptor/..%2Ftools%2Fstep.cwl") + + +async def test_get_tool_files(client: Client[Any]) -> None: + async with client: + result = await client.call_tool( + "get_tool_files", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL"} + ) + assert [(file.path, file.file_type) for file in result.data] == [ + ("main.cwl", "PRIMARY_DESCRIPTOR"), + (SECONDARY_PATH, "SECONDARY_DESCRIPTOR"), + ] + assert result.data[1].checksum.checksum == "789abc" + + +async def test_get_tool_tests(client: Client[Any]) -> None: + async with client: + result = await client.call_tool( + "get_tool_tests", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL"} + ) + assert [test.content for test in result.data] == ['{"input": 1}'] + + +async def test_get_tool_containerfile(client: Client[Any]) -> None: + async with client: + result = await client.call_tool("get_tool_containerfile", {"tool_id": TOOL_ID, "version_id": VERSION_ID}) + assert result.data[0].content == "FROM ubuntu:24.04\n" + + +async def test_get_tool_rejects_unknown_descriptor_types(client: Client[Any]) -> None: + async with client: + with pytest.raises(ToolError): + await client.call_tool( + "get_tool_descriptor", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "PLAIN_CWL"} + ) + + +async def test_get_tool_surfaces_not_found(client: Client[Any]) -> None: + async with client: + with pytest.raises(ToolError): + await client.call_tool("get_tool", {"tool_id": "#workflow/github.com/org/missing"}) From 3524c880e1e74bd515d200fee6bf9fc582e0693e Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Wed, 23 Sep 2026 16:53:09 -0400 Subject: [PATCH 05/21] Send a dockstore-mcp/ User-Agent with TRS requests The ref comes from DOCKSTORE_MCP_GIT_REF, which the Docker image sets from a GIT_REF build argument (filled in by the tagged deploy workflow and the Makefile), falling back to the package version when unset. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 ++++ .github/workflows/deploy_tagged.yml | 1 + Dockerfile | 7 ++++++- Makefile | 8 +++++--- README.md | 9 ++++++--- src/dockstore_mcp/config.py | 15 +++++++++++++++ src/dockstore_mcp/tools/trs.py | 2 +- tests/test_config.py | 12 ++++++++++++ tests/test_trs.py | 12 ++++++++++-- 9 files changed, 60 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 7609c53..15f4697 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,7 @@ DOCKSTORE_MCP_LOG_LEVEL=INFO # The Dockstore instance whose APIs this server exposes. DOCKSTORE_MCP_DOCKSTORE_URL=https://dockstore.org + +# Git tag or ref reported in the User-Agent sent to Dockstore. Normally set at +# build time; left unset, the package version is used. +# DOCKSTORE_MCP_GIT_REF= diff --git a/.github/workflows/deploy_tagged.yml b/.github/workflows/deploy_tagged.yml index 11c547b..556f0fb 100644 --- a/.github/workflows/deploy_tagged.yml +++ b/.github/workflows/deploy_tagged.yml @@ -45,6 +45,7 @@ jobs: context: . push: true tags: quay.io/dockstore/dockstore-mcp:${{ steps.ref.outputs.sanitized }} + build-args: GIT_REF=${{ steps.ref.outputs.sanitized }} - name: Create checksums run: | diff --git a/Dockerfile b/Dockerfile index 503313b..1b02891 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,10 @@ RUN pip install . # ---- runtime -------------------------------------------------------------- FROM python:3.13-slim-bookworm +# Git tag or ref the image is built from, reported in the User-Agent sent to +# Dockstore. Left empty, the server falls back to the package version. +ARG GIT_REF="" + LABEL org.opencontainers.image.title="dockstore-mcp" \ org.opencontainers.image.description="Model Context Protocol server for Dockstore" \ org.opencontainers.image.url="https://dockstore.org" \ @@ -54,7 +58,8 @@ ENV PATH="/opt/venv/bin:${PATH}" \ DOCKSTORE_MCP_HOST=0.0.0.0 \ DOCKSTORE_MCP_PORT=8000 \ DOCKSTORE_MCP_PATH=/mcp \ - DOCKSTORE_MCP_DOCKSTORE_URL=https://dockstore.org + DOCKSTORE_MCP_DOCKSTORE_URL=https://dockstore.org \ + DOCKSTORE_MCP_GIT_REF=${GIT_REF} USER dockstore WORKDIR /home/dockstore diff --git a/Makefile b/Makefile index ce42d2e..f1a4dc4 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,8 @@ VENV ?= .venv PY ?= $(VENV)/bin/python PIP ?= $(VENV)/bin/pip IMAGE ?= dockstore/dockstore-mcp:local +# Reported in the User-Agent the server sends to Dockstore. +GIT_REF ?= $(shell git describe --tags --always 2>/dev/null) help: ## Show this help @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' @@ -35,13 +37,13 @@ typecheck: ## Run the type checker check: lint typecheck test ## Everything CI runs run: ## Run the server over stdio - $(VENV)/bin/dockstore-mcp + DOCKSTORE_MCP_GIT_REF=$(GIT_REF) $(VENV)/bin/dockstore-mcp run-http: ## Run the server over HTTP on port 8000 - $(VENV)/bin/dockstore-mcp --transport http --port 8000 + DOCKSTORE_MCP_GIT_REF=$(GIT_REF) $(VENV)/bin/dockstore-mcp --transport http --port 8000 docker-build: ## Build the container image - docker build -t $(IMAGE) . + docker build --build-arg GIT_REF=$(GIT_REF) -t $(IMAGE) . docker-run: ## Run the container image on port 8000 docker run --rm -p 8000:8000 $(IMAGE) diff --git a/README.md b/README.md index 4bf0baa..a7c5570 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,14 @@ over the environment. | `DOCKSTORE_MCP_PATH` | `--path` | `/mcp` | Path the MCP endpoint is served from | | `DOCKSTORE_MCP_LOG_LEVEL` | `--log-level` | `INFO` | Logging verbosity | | `DOCKSTORE_MCP_DOCKSTORE_URL` | `--dockstore-url` | `https://dockstore.org` | Dockstore instance whose APIs are exposed | +| `DOCKSTORE_MCP_GIT_REF` | | package version | Version in the `dockstore-mcp/` User-Agent | The container image overrides the first four so that it listens on `0.0.0.0:8000` out of -the box. It also sets a few `FASTMCP_*` variables so that a deployed server logs plainly -and does not check PyPI for updates on startup; see the -[FastMCP settings](https://gofastmcp.com) for the full list. +the box, and sets `DOCKSTORE_MCP_GIT_REF` from its `GIT_REF` build argument, which the +release workflow and `make docker-build` fill in with the git tag or ref being built. It +also sets a few `FASTMCP_*` variables so that a deployed server logs plainly and does not +check PyPI for updates on startup; see the [FastMCP settings](https://gofastmcp.com) for +the full list. ## Tools diff --git a/src/dockstore_mcp/config.py b/src/dockstore_mcp/config.py index 4978f4e..23d1f8a 100644 --- a/src/dockstore_mcp/config.py +++ b/src/dockstore_mcp/config.py @@ -24,6 +24,8 @@ from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from dockstore_mcp import __version__ + Transport = Literal["stdio", "http"] LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] @@ -52,6 +54,14 @@ class Settings(BaseSettings): description="Base URL of the Dockstore instance whose APIs this server exposes.", ) + git_ref: str | None = Field( + default=None, + description=( + "Git tag or ref this server was built from, e.g. '1.21.0'; set at build time. " + "Falls back to the package version." + ), + ) + @field_validator("dockstore_url") @classmethod def _strip_trailing_slash(cls, value: str) -> str: @@ -74,6 +84,11 @@ def api_url(self) -> str: """Base URL of the instance's proprietary Dockstore API.""" return f"{self.dockstore_url}/api" + @property + def user_agent(self) -> str: + """User-Agent sent with every request to Dockstore, e.g. 'dockstore-mcp/1.21.0'.""" + return f"dockstore-mcp/{self.git_ref or __version__}" + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 37d299a..2496778 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -79,7 +79,7 @@ def register(mcp: FastMCP, settings: Settings) -> None: # Shared for every call this server handles, so the tools below don't pay a # fresh TCP/TLS handshake to Dockstore on every invocation. Reuse this same # client as more TRS-backed tools join this module. - client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT) + client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT, headers={"User-Agent": settings.user_agent}) async def get_json(path: str, params: dict[str, Any] | None = None) -> Any: """GET ``path`` under the TRS API root and return its parsed body. diff --git a/tests/test_config.py b/tests/test_config.py index 43de520..0550be0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,7 @@ import pytest from pydantic import ValidationError +from dockstore_mcp import __version__ from dockstore_mcp.config import Settings @@ -41,6 +42,17 @@ def test_derived_api_urls() -> None: assert settings.api_url == "https://qa.dockstore.org/api" +def test_user_agent_reports_the_git_ref(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DOCKSTORE_MCP_GIT_REF", "1.21.0") + assert Settings().user_agent == "dockstore-mcp/1.21.0" + + +def test_user_agent_falls_back_to_the_package_version(monkeypatch: pytest.MonkeyPatch) -> None: + # The Dockerfile sets DOCKSTORE_MCP_GIT_REF to "" when built without GIT_REF. + monkeypatch.setenv("DOCKSTORE_MCP_GIT_REF", "") + assert Settings().user_agent == f"dockstore-mcp/{__version__}" + + @pytest.mark.parametrize(("field", "value"), [("port", 0), ("path", "mcp"), ("transport", "carrier-pigeon")]) def test_rejects_bad_values(field: str, value: object) -> None: with pytest.raises(ValidationError): diff --git a/tests/test_trs.py b/tests/test_trs.py index 5d05d9f..1c8b404 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -25,6 +25,8 @@ from fastmcp import Client from fastmcp.exceptions import ToolError +from dockstore_mcp import __version__ + SERVICE_INFO_RESPONSE = { "id": "org.dockstore.staging", "name": "Dockstore", @@ -129,8 +131,8 @@ def _mock_client_factory(handler: Any) -> Any: """Build a stand-in for ``httpx.AsyncClient`` that routes every request through ``handler``.""" - def fake_client(*, timeout: float) -> httpx.AsyncClient: - return _RealAsyncClient(timeout=timeout, transport=httpx.MockTransport(handler)) + def fake_client(**kwargs: Any) -> httpx.AsyncClient: + return _RealAsyncClient(**kwargs, transport=httpx.MockTransport(handler)) return fake_client @@ -306,3 +308,9 @@ async def test_get_tool_surfaces_not_found(client: Client[Any]) -> None: async with client: with pytest.raises(ToolError): await client.call_tool("get_tool", {"tool_id": "#workflow/github.com/org/missing"}) + + +async def test_requests_identify_the_server(client: Client[Any], requests_made: list[httpx.Request]) -> None: + async with client: + await client.call_tool("get_trs_info", {}) + assert requests_made[0].headers["User-Agent"] == f"dockstore-mcp/{__version__}" From 505973c8c381f40d2238e6f05164cd2300f193f3 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Wed, 23 Sep 2026 16:58:12 -0400 Subject: [PATCH 06/21] Report totals from list_tools/search_tools and support notebooks list_tools and search_tools now return a ToolPage with the total across every page and the next page's offset. Dockstore only reports the last page's offset, computed as floor(total / limit), so the total is counted from the last page's contents rather than read from the header. Adds the JUPYTER and SERVICE descriptor types Dockstore's TRS API accepts, so notebooks' descriptors and files can be fetched. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/dockstore_mcp/models.py | 17 ++++++ src/dockstore_mcp/tools/trs.py | 67 ++++++++++++++++++------ tests/test_trs.py | 96 ++++++++++++++++++++++++++++------ 4 files changed, 149 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a7c5570..ed8e028 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,12 @@ the full list. | `hello` | Greets the caller and reports the Dockstore instance and server version. No I/O. | | `get_trs_info` | Describes this instance's GA4GH TRS API: identifiers, version, and operator. | | `list_tool_classes` | Lists the tool classes (e.g. `Workflow`) this instance's TRS API sorts entries into. | -| `list_tools` | Lists one page of every tool and workflow the TRS API serves. | +| `list_tools` | Lists one page of every tool and workflow the TRS API serves, with the total count. | | `search_tools` | Finds TRS tools by name, organization, author, class, descriptor language, etc. | | `get_tool` | Retrieves one TRS tool by id, including all of its versions. | | `list_tool_versions` | Lists every version of one TRS tool. | | `get_tool_version` | Retrieves one version of a TRS tool: authors, images, descriptor languages. | -| `get_tool_descriptor` | Fetches the primary descriptor (CWL, WDL, etc.) of a version. | +| `get_tool_descriptor` | Fetches the primary descriptor (CWL, WDL, etc., or a notebook) of a version. | | `get_tool_descriptor_by_path` | Fetches one of a version's files by its relative path. | | `get_tool_files` | Lists every file of a version, without content. | | `get_tool_tests` | Fetches a version's test parameter files. | diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index c02c095..da29623 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -45,6 +45,7 @@ "Tool", "ToolClass", "ToolFile", + "ToolPage", "ToolVersion", "TrsDescriptorType", "TrsInfo", @@ -231,6 +232,8 @@ class TrsDescriptorType(StrEnum): NEXTFLOW = "NFL" GALAXY = "GALAXY" SNAKEMAKE = "SMK" + JUPYTER = "JUPYTER" + SERVICE = "SERVICE" class Checksum(BaseModel): @@ -290,6 +293,20 @@ class Tool(BaseModel): versions: list[ToolVersion] | None = Field(default=None, description="Every version of the tool.") +class ToolPage(BaseModel): + """One page of TRS tools, with enough context to fetch the rest.""" + + tools: list[Tool] = Field(description="The tools on this page, each with all of its versions.") + offset: int = Field(description="Which page this is, counting from 0.") + limit: int = Field(description="Most tools a page holds.") + total: int | None = Field( + default=None, description="How many tools there are across every page, if Dockstore reported it." + ) + next_offset: int | None = Field( + default=None, description="Offset of the next page; unset when this is the last page." + ) + + class FileWrapper(BaseModel): """The content of one file from a TRS tool version: a descriptor, test parameter file, or containerfile.""" diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 2496778..2da8308 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -36,6 +36,7 @@ Tool, ToolClass, ToolFile, + ToolPage, ToolVersion, TrsDescriptorType, TrsInfo, @@ -73,6 +74,13 @@ def _segment(value: str) -> str: return quote(value, safe="") +def _last_page_offset(response: httpx.Response) -> int | None: + """The offset in a ``/tools`` response's ``last_page`` header, if it has one.""" + link = response.headers.get("last_page") + offset = httpx.URL(link).params.get("offset") if link else None + return int(offset) if offset is not None and offset.isdigit() else None + + def register(mcp: FastMCP, settings: Settings) -> None: """Add the TRS V2 tools to ``mcp``.""" @@ -81,6 +89,12 @@ def register(mcp: FastMCP, settings: Settings) -> None: # client as more TRS-backed tools join this module. client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT, headers={"User-Agent": settings.user_agent}) + async def get(path: str, params: dict[str, Any] | None = None) -> httpx.Response: + """GET ``path`` under the TRS API root, raising on an error status.""" + response = await client.get(f"{settings.trs_url}{path}", params=params) + response.raise_for_status() + return response + async def get_json(path: str, params: dict[str, Any] | None = None) -> Any: """GET ``path`` under the TRS API root and return its parsed body. @@ -88,9 +102,29 @@ async def get_json(path: str, params: dict[str, Any] | None = None) -> Any: keys, so they skip ``normalize_keys``, which would also mangle data-valued keys such as the 'CWL' in ``descriptor_type_version``. """ - response = await client.get(f"{settings.trs_url}{path}", params=params) - response.raise_for_status() - return response.json() + return (await get(path, params)).json() + + async def get_tool_page(filters: dict[str, Any], limit: int, offset: int) -> ToolPage: + """Fetch one page of ``/tools`` matching ``filters``, and work out the total across every page. + + Dockstore reports the last page's offset in a ``last_page`` header but no + total, and computes that offset as ``floor(total / limit)``, which is one + page past the end whenever ``limit`` divides the total evenly. So the total + is counted from the last page's contents, fetching it if this isn't it. + """ + response = await get("/tools", {**filters, "limit": limit, "offset": offset}) + tools = [Tool.model_validate(item) for item in response.json()] + total = None + last_offset = _last_page_offset(response) + if last_offset is not None: + if last_offset == offset: + last_page_size = len(tools) + else: + last_page = await get_json("/tools", {**filters, "limit": limit, "offset": last_offset}) + last_page_size = len(last_page) + total = last_offset * limit + last_page_size + more = total is not None and (offset + 1) * limit < total + return ToolPage(tools=tools, offset=offset, limit=limit, total=total, next_offset=offset + 1 if more else None) def version_path(tool_id: str, version_id: str) -> str: return f"/tools/{_segment(tool_id)}/versions/{_segment(version_id)}" @@ -128,19 +162,19 @@ async def list_tool_classes() -> list[ToolClass]: return [ToolClass.model_validate(item) for item in normalize_keys(response.json())] @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0) -> list[Tool]: + async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0) -> ToolPage: """List one page of every tool and workflow this Dockstore instance's TRS API serves. Reach for search_tools instead to narrow the list by name, language, class, - or other filters; this one pages through everything. Ask for the next page by - incrementing ``offset`` by one (it is a page number, not an item index); a - page shorter than ``limit`` is the last one. + or other filters; this one pages through everything. The page's ``total`` + says how many tools there are in all, so to count them, ask for one page + with ``limit`` 1. Fetch the next page by passing ``next_offset`` as + ``offset`` (a page number, not an item index); it is unset on the last page. Returns: - Up to ``limit`` tools, each with all of its versions. + Up to ``limit`` tools, each with all of its versions, plus the total and next page's offset. """ - data = await get_json("/tools", {"limit": limit, "offset": offset}) - return [Tool.model_validate(item) for item in data] + return await get_tool_page({}, limit, offset) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def search_tools( @@ -166,15 +200,15 @@ async def search_tools( ] = None, limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0, - ) -> list[Tool]: + ) -> ToolPage: """Find tools and workflows through the TRS API by name, language, class, and other filters. Every filter given must match; text filters match substrings. Page through - the results as with list_tools. For richer keyword search with facets, the - Dockstore Search page equivalent is search_entries. + the results, or count them, as with list_tools. For richer keyword search + with facets, the Dockstore Search page equivalent is search_entries. Returns: - Up to ``limit`` matching tools, each with all of its versions. + Up to ``limit`` matching tools, each with all of its versions, plus the total and next page's offset. """ filters = { "name": name, @@ -189,8 +223,7 @@ async def search_tools( "checker": None if checker is None else str(checker).lower(), } params = {key: value for key, value in filters.items() if value is not None} - data = await get_json("/tools", {**params, "limit": limit, "offset": offset}) - return [Tool.model_validate(item) for item in data] + return await get_tool_page(params, limit, offset) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_tool(tool_id: ToolId) -> Tool: @@ -228,7 +261,7 @@ async def get_tool_version(tool_id: ToolId, version_id: VersionId) -> ToolVersio async def get_tool_descriptor( tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType ) -> FileWrapper: - """Fetch the primary descriptor of one version: the main CWL, WDL, Nextflow, etc. file. + """Fetch the primary descriptor of one version: the main CWL, WDL, Nextflow, etc. file, or notebook. Reach for get_tool_files to see what other files the version has, and get_tool_descriptor_by_path to fetch one of them. diff --git a/tests/test_trs.py b/tests/test_trs.py index 1c8b404..16320ab 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -113,7 +113,6 @@ RESPONSES: dict[str, Any] = { "/service-info": SERVICE_INFO_RESPONSE, "/toolClasses": TOOL_CLASSES_RESPONSE, - "/tools": [TOOL_RESPONSE], f"/tools/{TOOL_ID}": TOOL_RESPONSE, f"/tools/{TOOL_ID}/versions": [VERSION_RESPONSE], _VERSION_PATH: VERSION_RESPONSE, @@ -122,8 +121,26 @@ f"{_VERSION_PATH}/CWL/files": FILES_RESPONSE, f"{_VERSION_PATH}/CWL/tests": TESTS_RESPONSE, f"{_VERSION_PATH}/containerfile": CONTAINERFILE_RESPONSE, + f"{_VERSION_PATH}/JUPYTER/files": [{"checksum": None, "file_type": "PRIMARY_DESCRIPTOR", "path": "main.ipynb"}], } +#: Every tool the fake ``/tools`` endpoint pages through: TOOL_RESPONSE, then six more. +CATALOG = [TOOL_RESPONSE, *({**TOOL_RESPONSE, "id": f"{TOOL_ID}-{i}"} for i in range(1, 7))] + + +def _tools_page(request: httpx.Request) -> httpx.Response: + """Serve one page of CATALOG the way Dockstore does. + + That includes its ``last_page`` header, whose offset Dockstore computes as + ``floor(total / limit)``: one page past the end when ``limit`` divides the total. + """ + limit = int(request.url.params["limit"]) + offset = int(request.url.params["offset"]) + last_page = request.url.copy_merge_params({"offset": str(len(CATALOG) // limit)}) + page = CATALOG[offset * limit : (offset + 1) * limit] + return httpx.Response(200, json=page, headers={"last_page": str(last_page)}) + + #: The real class, captured before any test monkeypatches ``httpx.AsyncClient``. _RealAsyncClient = httpx.AsyncClient @@ -159,6 +176,8 @@ def handler(request: httpx.Request) -> httpx.Response: path = request.url.path.removeprefix("/api/ga4gh/trs/v2") if path in overrides: return overrides[path] + if path == "/tools": + return _tools_page(request) if path not in RESPONSES: return httpx.Response(404, json={"error": "not found"}) return httpx.Response(200, json=RESPONSES[path]) @@ -195,11 +214,50 @@ async def test_get_trs_info_surfaces_http_errors(client: Client[Any], _mock_trs_ async def test_list_tools_pages(client: Client[Any], requests_made: list[httpx.Request]) -> None: async with client: - result = await client.call_tool("list_tools", {"limit": 5, "offset": 2}) - assert [tool.id for tool in result.data] == [TOOL_ID] - assert result.data[0].toolclass.name == "Workflow" - assert result.data[0].versions[0].name == VERSION_ID - assert dict(requests_made[0].url.params) == {"limit": "5", "offset": "2"} + result = await client.call_tool("list_tools", {"limit": 5, "offset": 0}) + assert [tool.id for tool in result.data.tools] == [tool["id"] for tool in CATALOG[:5]] + assert result.data.tools[0].toolclass.name == "Workflow" + assert result.data.tools[0].versions[0].name == VERSION_ID + assert (result.data.offset, result.data.limit, result.data.total, result.data.next_offset) == (0, 5, 7, 1) + # The total needs the last page's size, so that page is fetched too. + assert [dict(request.url.params) for request in requests_made] == [ + {"limit": "5", "offset": "0"}, + {"limit": "5", "offset": "1"}, + ] + + +async def test_list_tools_last_page(client: Client[Any], requests_made: list[httpx.Request]) -> None: + async with client: + result = await client.call_tool("list_tools", {"limit": 5, "offset": 1}) + assert len(result.data.tools) == 2 + assert (result.data.total, result.data.next_offset) == (7, None) + assert len(requests_made) == 1 + + +@pytest.mark.parametrize("limit", [1, 7]) +async def test_list_tools_counts_evenly_divided_totals(client: Client[Any], limit: int) -> None: + # Dockstore's last_page points one page past the end here, at an empty page. + async with client: + result = await client.call_tool("list_tools", {"limit": limit, "offset": 0}) + assert result.data.total == 7 + assert result.data.next_offset == (1 if limit == 1 else None) + + +async def test_list_tools_past_the_end(client: Client[Any]) -> None: + async with client: + result = await client.call_tool("list_tools", {"limit": 5, "offset": 9}) + assert (result.data.tools, result.data.total, result.data.next_offset) == ([], 7, None) + + +async def test_list_tools_without_a_last_page_header( + client: Client[Any], _mock_trs_api: dict[str, httpx.Response] +) -> None: + _mock_trs_api["/tools"] = httpx.Response(200, json=[TOOL_RESPONSE]) + + async with client: + result = await client.call_tool("list_tools", {}) + assert [tool.id for tool in result.data.tools] == [TOOL_ID] + assert (result.data.total, result.data.next_offset) == (None, None) async def test_list_tools_defaults_to_a_small_page(client: Client[Any], requests_made: list[httpx.Request]) -> None: @@ -212,17 +270,15 @@ async def test_search_tools_sends_only_given_filters(client: Client[Any], reques async with client: result = await client.call_tool( "search_tools", - {"toolname": "name", "tool_class": "Workflow", "descriptor_type": "NFL", "checker": False}, + {"toolname": "name", "tool_class": "Workflow", "descriptor_type": "NFL", "checker": False, "limit": 5}, ) - assert [tool.id for tool in result.data] == [TOOL_ID] - assert dict(requests_made[0].url.params) == { - "toolname": "name", - "toolClass": "Workflow", - "descriptorType": "NFL", - "checker": "false", - "limit": "20", - "offset": "0", - } + assert result.data.total == 7 + filters = {"toolname": "name", "toolClass": "Workflow", "descriptorType": "NFL", "checker": "false"} + # The last page, fetched for the total, is filtered the same way. + assert [dict(request.url.params) for request in requests_made] == [ + {**filters, "limit": "5", "offset": "0"}, + {**filters, "limit": "5", "offset": "1"}, + ] async def test_get_tool_encodes_the_id(client: Client[Any], requests_made: list[httpx.Request]) -> None: @@ -296,6 +352,14 @@ async def test_get_tool_containerfile(client: Client[Any]) -> None: assert result.data[0].content == "FROM ubuntu:24.04\n" +async def test_get_tool_files_for_a_notebook(client: Client[Any]) -> None: + async with client: + result = await client.call_tool( + "get_tool_files", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "JUPYTER"} + ) + assert [file.path for file in result.data] == ["main.ipynb"] + + async def test_get_tool_rejects_unknown_descriptor_types(client: Client[Any]) -> None: async with client: with pytest.raises(ToolError): From a2c193033f8112708255995fe34caf4a2b35fcd2 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Wed, 23 Sep 2026 17:03:22 -0400 Subject: [PATCH 07/21] Add a summary option to list_tools and search_tools With summary set, each tool comes back as a ToolSummary (id, name, organization, class, descriptor languages across its versions, version names, and the first 200 characters of its description) instead of the full TRS record. The nine RNA-seq name searches against production shrink from about 724 KB to 43 KB this way. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- src/dockstore_mcp/models.py | 21 ++++++++++++- src/dockstore_mcp/tools/trs.py | 54 +++++++++++++++++++++++++++++----- tests/test_trs.py | 37 +++++++++++++++++++++++ 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ed8e028..33c69f2 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,8 @@ the full list. The TRS tools, from `get_trs_info` to `get_tool_containerfile`, call Dockstore's GA4GH TRS V2 API directly. They form a chain: `list_tools` and `search_tools` yield tool ids, a tool yields version names, and a version's `get_tool_files` yields the paths that -`get_tool_descriptor_by_path` takes. +`get_tool_descriptor_by_path` takes. Pass `summary` to `list_tools` or `search_tools` to get +each tool's id, languages, and version names without its full README and version details. The last four are scaffolding and are not implemented yet. They are a chain too: `search_entries` yields entry identifiers, an entry yields version identifiers, and a diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index da29623..14f6950 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -46,6 +46,7 @@ "ToolClass", "ToolFile", "ToolPage", + "ToolSummary", "ToolVersion", "TrsDescriptorType", "TrsInfo", @@ -293,10 +294,28 @@ class Tool(BaseModel): versions: list[ToolVersion] | None = Field(default=None, description="Every version of the tool.") +class ToolSummary(BaseModel): + """The handful of fields that identify a TRS tool in a list, without its README or version details.""" + + id: str | None = Field(default=None, description="TRS identifier of the tool; pass this as tool_id.") + name: str | None = Field(default=None, description="Name of the tool.") + organization: str | None = Field(default=None, description="Organization that published the tool.") + tool_class: str | None = Field(default=None, description="Category of the tool, e.g. 'Workflow'.") + descriptor_types: list[str] = Field( + 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." + ) + description: str | None = Field(default=None, description="The start of the tool's description, shortened.") + + class ToolPage(BaseModel): """One page of TRS tools, with enough context to fetch the rest.""" - tools: list[Tool] = Field(description="The tools on this page, each with all of its versions.") + tools: list[Tool] | list[ToolSummary] = Field( + description="The tools on this page: in full, or as summaries if they were asked for." + ) offset: int = Field(description="Which page this is, counting from 0.") limit: int = Field(description="Most tools a page holds.") total: int | None = Field( diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 2da8308..3219d84 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -37,6 +37,7 @@ ToolClass, ToolFile, ToolPage, + ToolSummary, ToolVersion, TrsDescriptorType, TrsInfo, @@ -52,6 +53,9 @@ #: its versions. DEFAULT_PAGE_SIZE = 20 +#: How much of a tool's description a summary keeps. +SUMMARY_DESCRIPTION_LENGTH = 200 + ToolId = Annotated[ str, Field(description="TRS tool id, e.g. '#workflow/github.com/org/repo/name', as list_tools or search_tools give."), @@ -59,6 +63,15 @@ VersionId = Annotated[str, Field(description="Version name, e.g. 'master' or '1.0', as list_tool_versions gives.")] DescriptorType = Annotated[TrsDescriptorType, Field(description="Descriptor language of the files to fetch.")] Limit = Annotated[int, Field(ge=1, le=1000, description="Most tools to return in this page.")] +Summary = Annotated[ + 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." + ) + ), +] Offset = Annotated[ int, Field(ge=0, description="Which page to return, counting from 0: Dockstore treats offset as a page number."), @@ -74,6 +87,24 @@ def _segment(value: str) -> str: return quote(value, safe="") +def _summarize(tool: Tool) -> ToolSummary: + """Reduce ``tool`` to a :class:`ToolSummary`, shortening its description.""" + versions = tool.versions or [] + 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: + description = description[: SUMMARY_DESCRIPTION_LENGTH - 1].rstrip() + "…" + return ToolSummary( + id=tool.id, + name=tool.name, + 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], + description=description, + ) + + def _last_page_offset(response: httpx.Response) -> int | None: """The offset in a ``/tools`` response's ``last_page`` header, if it has one.""" link = response.headers.get("last_page") @@ -104,7 +135,7 @@ async def get_json(path: str, params: dict[str, Any] | None = None) -> Any: """ return (await get(path, params)).json() - async def get_tool_page(filters: dict[str, Any], limit: int, offset: int) -> ToolPage: + async def get_tool_page(filters: dict[str, Any], limit: int, offset: int, summary: bool) -> ToolPage: """Fetch one page of ``/tools`` matching ``filters``, and work out the total across every page. Dockstore reports the last page's offset in a ``last_page`` header but no @@ -124,7 +155,13 @@ async def get_tool_page(filters: dict[str, Any], limit: int, offset: int) -> Too last_page_size = len(last_page) total = last_offset * limit + last_page_size more = total is not None and (offset + 1) * limit < total - return ToolPage(tools=tools, offset=offset, limit=limit, total=total, next_offset=offset + 1 if more else None) + return ToolPage( + tools=[_summarize(tool) for tool in tools] if summary else tools, + offset=offset, + limit=limit, + total=total, + next_offset=offset + 1 if more else None, + ) def version_path(tool_id: str, version_id: str) -> str: return f"/tools/{_segment(tool_id)}/versions/{_segment(version_id)}" @@ -162,7 +199,7 @@ async def list_tool_classes() -> list[ToolClass]: return [ToolClass.model_validate(item) for item in normalize_keys(response.json())] @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0) -> ToolPage: + async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0, summary: Summary = False) -> ToolPage: """List one page of every tool and workflow this Dockstore instance's TRS API serves. Reach for search_tools instead to narrow the list by name, language, class, @@ -172,9 +209,10 @@ async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0) -> To ``offset`` (a page number, not an item index); it is unset on the last page. Returns: - Up to ``limit`` tools, each with all of its versions, plus the total and next page's offset. + Up to ``limit`` tools, each with all of its versions (or summarized, if ``summary``), plus the + total and next page's offset. """ - return await get_tool_page({}, limit, offset) + return await get_tool_page({}, limit, offset, summary) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def search_tools( @@ -200,6 +238,7 @@ async def search_tools( ] = None, limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0, + summary: Summary = False, ) -> ToolPage: """Find tools and workflows through the TRS API by name, language, class, and other filters. @@ -208,7 +247,8 @@ async def search_tools( with facets, the Dockstore Search page equivalent is search_entries. Returns: - Up to ``limit`` matching tools, each with all of its versions, plus the total and next page's offset. + Up to ``limit`` matching tools, each with all of its versions (or summarized, if ``summary``), plus + the total and next page's offset. """ filters = { "name": name, @@ -223,7 +263,7 @@ async def search_tools( "checker": None if checker is None else str(checker).lower(), } params = {key: value for key, value in filters.items() if value is not None} - return await get_tool_page(params, limit, offset) + return await get_tool_page(params, limit, offset, summary) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_tool(tool_id: ToolId) -> Tool: diff --git a/tests/test_trs.py b/tests/test_trs.py index 16320ab..376d146 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -260,6 +260,43 @@ async def test_list_tools_without_a_last_page_header( assert (result.data.total, result.data.next_offset) == (None, None) +async def test_list_tools_summarizes(client: Client[Any], _mock_trs_api: dict[str, httpx.Response]) -> None: + long_readme = "# Title\n\n" + "word " * 100 + tool = {**TOOL_RESPONSE, "description": long_readme} + tool["versions"] = [VERSION_RESPONSE, {**VERSION_RESPONSE, "name": "1.0", "descriptor_type": ["WDL", "CWL"]}] + _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["id"] == TOOL_ID + assert summary["tool_class"] == "Workflow" + assert summary["descriptor_types"] == ["CWL", "WDL"] + assert summary["version_names"] == [VERSION_ID, "1.0"] + assert summary["description"].startswith("# Title word word") + assert len(summary["description"]) == 200 + assert summary["description"].endswith("…") + assert "versions" not in summary + + +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}) + page = result.structured_content + assert page is not None + assert [tool["id"] for tool in page["tools"]] == [tool["id"] for tool in CATALOG[:5]] + assert page["total"] == 7 + assert page["tools"][0]["description"] == "Does a thing." + + +async def test_list_tools_returns_full_tools_by_default(client: Client[Any]) -> None: + async with client: + result = await client.call_tool("list_tools", {"limit": 1}) + assert result.structured_content is not None + assert result.structured_content["tools"][0]["versions"][0]["name"] == VERSION_ID + + async def test_list_tools_defaults_to_a_small_page(client: Client[Any], requests_made: list[httpx.Request]) -> None: async with client: await client.call_tool("list_tools", {}) From 62e6e69aa7fd63c9c518ede617cc04cae2c2bbe4 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Thu, 24 Sep 2026 15:19:46 -0400 Subject: [PATCH 08/21] Cap version names in list_tools/search_tools summaries Monorepo workflows such as broadinstitute/warp have a version for every branch and tag of the repository, so a summary of one ReblockGVCF search was 57 KB, 94% of it version names. Summaries now list at most 10 names, production-ready versions first, plus version_count and versions_truncated so callers know when the list is incomplete. The same search is now 5 KB. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/dockstore_mcp/models.py | 10 +++++++++- src/dockstore_mcp/tools/trs.py | 19 +++++++++++++++---- tests/test_trs.py | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 14f6950..ee68a24 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -305,7 +305,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..20c83eb 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -56,6 +56,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 +71,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 +93,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 +109,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, ) diff --git a/tests/test_trs.py b/tests/test_trs.py index 376d146..ce8f32a 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}) From 825c0f27a0ae060da3ac74e8c7ef447df30d4f95 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Thu, 24 Sep 2026 15:39:14 -0400 Subject: [PATCH 09/21] Add a summary option to list_tool_versions Monorepo workflows list a version for every branch and tag of their repository: broadinstitute/warp's ReblockGVCF has 1,433, a 793 KB response. With summary, each version is just its name, meta_version and is_production, which brings that entry down to 151 KB. Paging through the versions is left to a follow-up ticket, since Dockstore's TRS endpoint ignores limit and offset. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/dockstore_mcp/models.py | 9 +++++++++ src/dockstore_mcp/tools/trs.py | 25 ++++++++++++++++++++++--- tests/test_trs.py | 9 +++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index ee68a24..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.""" diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 20c83eb..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, ) @@ -286,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 ce8f32a..68dbe4e 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -346,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}) From 118247a1a9d64ee2c03367fae112c330f93573f1 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Thu, 24 Sep 2026 17:11:13 -0400 Subject: [PATCH 10/21] Merge list_tool_classes into get_trs_info list_tool_classes only added a little more about the TRS instance, so an agent needed two calls to learn what it was talking to. get_trs_info now fetches service-info and toolClasses concurrently and returns the classes as tool_classes, and list_tool_classes is removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 ++--- src/dockstore_mcp/models.py | 6 +++++- src/dockstore_mcp/tools/trs.py | 36 ++++++++++++---------------------- tests/test_server.py | 1 - tests/test_trs.py | 16 +++++++-------- 5 files changed, 26 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 33c69f2..25d552f 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,7 @@ the full list. | Tool | Description | | ----------------------------- | ------------------------------------------------------------------------------------ | | `hello` | Greets the caller and reports the Dockstore instance and server version. No I/O. | -| `get_trs_info` | Describes this instance's GA4GH TRS API: identifiers, version, and operator. | -| `list_tool_classes` | Lists the tool classes (e.g. `Workflow`) this instance's TRS API sorts entries into. | +| `get_trs_info` | Describes this instance's TRS API: identifiers, version, operator, and tool classes. | | `list_tools` | Lists one page of every tool and workflow the TRS API serves, with the total count. | | `search_tools` | Finds TRS tools by name, organization, author, class, descriptor language, etc. | | `get_tool` | Retrieves one TRS tool by id, including all of its versions. | @@ -172,7 +171,7 @@ src/dockstore_mcp/ ├── entries.py get_entry, get_version, get_file ├── hello.py the hello tool ├── search.py search_entries - └── trs.py get_trs_info, list_tool_classes, and the other GA4GH TRS tools + └── trs.py get_trs_info and the other GA4GH TRS tools tests/ pytest suite, using FastMCP's in-memory client Dockerfile two-stage build of the deployable image ``` diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 4a5e020..b27090c 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -201,7 +201,7 @@ class ServiceOrganization(BaseModel): class TrsInfo(BaseModel): - """GA4GH TRS service-info: metadata describing a Dockstore instance's TRS API.""" + """GA4GH TRS service-info, plus the tool classes the service sorts entries into.""" id: str = Field(description="Unique identifier of this service, in reverse domain name notation.") name: str = Field(description="Human-readable name of this service.") @@ -216,6 +216,10 @@ class TrsInfo(BaseModel): ) created_at: datetime | None = Field(default=None, description="When the service was first deployed.") updated_at: datetime | None = Field(default=None, description="When the service was last updated.") + tool_classes: list["ToolClass"] = Field( + default_factory=list, + description="Every tool class (e.g. 'Workflow') the service sorts entries into; search_tools filters by these.", + ) class ToolClass(BaseModel): diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 43510f1..4625d08 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -22,6 +22,7 @@ ``get_tool_files`` listing yields the paths ``get_tool_descriptor_by_path`` takes. """ +import asyncio from typing import Annotated, Any from urllib.parse import quote @@ -183,32 +184,19 @@ async def get_trs_info() -> TrsInfo: """Describe this Dockstore instance's GA4GH Tool Registry Service (TRS) API. Use this to identify which Dockstore instance a server is talking to, which - version of the TRS API it implements, and who operates it. It takes no - arguments and always describes the ``dockstore_url`` this server is - configured with. + version of the TRS API it implements, and who operates it, and to see which + tool classes (for example 'Workflow' or 'CommandLineTool') it sorts entries + into, e.g. before filtering search_tools by one. It takes no arguments and + always describes the ``dockstore_url`` this server is configured with. Returns: - Service metadata: identifiers, the TRS API version implemented, and the - organization operating the service. + Service metadata: identifiers, the TRS API version implemented, the + organization operating the service, and every tool class it recognizes. """ - response = await client.get(f"{settings.trs_url}/service-info") - response.raise_for_status() - return TrsInfo.model_validate(normalize_keys(response.json())) - - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def list_tool_classes() -> list[ToolClass]: - """List the tool classes this Dockstore instance's TRS API sorts entries into. - - A tool class (for example 'Workflow' or 'CommandLineTool') is the category - Dockstore assigns an entry under the GA4GH TRS API. Reach for this to see - which classes exist, for example before filtering a TRS-level lookup by one. - - Returns: - Every tool class the service recognizes. - """ - response = await client.get(f"{settings.trs_url}/toolClasses") - response.raise_for_status() - return [ToolClass.model_validate(item) for item in normalize_keys(response.json())] + service_info, tool_classes = await asyncio.gather(get("/service-info"), get("/toolClasses")) + info = TrsInfo.model_validate(normalize_keys(service_info.json())) + info.tool_classes = [ToolClass.model_validate(item) for item in normalize_keys(tool_classes.json())] + return info @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0, summary: Summary = False) -> ToolPage: @@ -240,7 +228,7 @@ async def search_tools( ] = None, alias: Annotated[str | None, Field(description="Match against one of the tool's aliases.")] = None, tool_class: Annotated[ - str | None, Field(description="Only tools of this class, e.g. 'Workflow'; see list_tool_classes.") + str | None, Field(description="Only tools of this class, e.g. 'Workflow'; see get_trs_info.") ] = None, descriptor_type: Annotated[ TrsDescriptorType | None, Field(description="Only tools available in this descriptor language.") diff --git a/tests/test_server.py b/tests/test_server.py index 3993e03..dcfd3c1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -37,7 +37,6 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: "get_trs_info", "get_version", "hello", - "list_tool_classes", "list_tool_versions", "list_tools", "search_entries", diff --git a/tests/test_trs.py b/tests/test_trs.py index 68dbe4e..2a1eb4e 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -195,17 +195,15 @@ async def test_get_trs_info(client: Client[Any]) -> None: assert result.data.type.group == "org.ga4gh" assert result.data.organization.name == "Dockstore" assert result.data.contact_url == "mailto:support@dockstore.org" + assert [tool_class.id for tool_class in result.data.tool_classes] == ["CommandLineTool", "Workflow"] + assert result.data.tool_classes[1].name == "Workflow" -async def test_list_tool_classes(client: Client[Any]) -> None: - async with client: - result = await client.call_tool("list_tool_classes", {}) - assert [tool_class.id for tool_class in result.data] == ["CommandLineTool", "Workflow"] - assert result.data[1].name == "Workflow" - - -async def test_get_trs_info_surfaces_http_errors(client: Client[Any], _mock_trs_api: dict[str, httpx.Response]) -> None: - _mock_trs_api["/service-info"] = httpx.Response(500) +@pytest.mark.parametrize("path", ["/service-info", "/toolClasses"]) +async def test_get_trs_info_surfaces_http_errors( + client: Client[Any], _mock_trs_api: dict[str, httpx.Response], path: str +) -> None: + _mock_trs_api[path] = httpx.Response(500) async with client: with pytest.raises(ToolError): From cd2c9a05aad3c1ab1875f21c1bf503660eecf5b4 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Thu, 24 Sep 2026 17:32:47 -0400 Subject: [PATCH 11/21] Fold the TRS file tools into get_tool_descriptor_by_path get_tool_descriptor, get_tool_tests and get_tool_containerfile each fetch a file that get_tool_files already lists, and get_tool_descriptor_by_path returns the same content for every one of them. Remove the three tools and make relative_path optional, so omitting it still fetches the primary descriptor without waiting on get_tool_files. That leaves 13 tools and trims about 5.8 KB from the tool schemas agents load. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 11 +++--- src/dockstore_mcp/tools/trs.py | 66 ++++++++++------------------------ tests/test_server.py | 3 -- tests/test_trs.py | 36 ++++++++++--------- 4 files changed, 43 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 25d552f..36fc31c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ MCP, so it is deployed alongside the Dockstore webservice rather than inside it. It is built on [FastMCP](https://gofastmcp.com) 4 and ships as a container image. > **Status: scaffold.** `hello` and the GA4GH TRS tools (`get_trs_info` through -> `get_tool_containerfile`) have working bodies; the other four Dockstore tools are +> `get_tool_descriptor_by_path`) have working bodies; the other four Dockstore tools are > declared — names, arguments, and response shapes — but each one raises > `NotImplementedError` until it is wired up to the Dockstore API. @@ -136,17 +136,14 @@ the full list. | `get_tool` | Retrieves one TRS tool by id, including all of its versions. | | `list_tool_versions` | Lists every version of one TRS tool. | | `get_tool_version` | Retrieves one version of a TRS tool: authors, images, descriptor languages. | -| `get_tool_descriptor` | Fetches the primary descriptor (CWL, WDL, etc., or a notebook) of a version. | -| `get_tool_descriptor_by_path` | Fetches one of a version's files by its relative path. | -| `get_tool_files` | Lists every file of a version, without content. | -| `get_tool_tests` | Fetches a version's test parameter files. | -| `get_tool_containerfile` | Fetches the containerfile (e.g. Dockerfile) that builds a version's image. | +| `get_tool_files` | Lists every file of a version (descriptors, tests, containerfile), without content. | +| `get_tool_descriptor_by_path` | Fetches a version's primary descriptor, or any file get_tool_files lists, by path. | | `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | | `get_entry` | Retrieves the requested fields of one entry. | | `get_version` | Retrieves the requested fields of one version of an entry. | | `get_file` | Retrieves the requested fields of one file belonging to a version. | -The TRS tools, from `get_trs_info` to `get_tool_containerfile`, call Dockstore's GA4GH +The TRS tools, from `get_trs_info` to `get_tool_descriptor_by_path`, call Dockstore's GA4GH TRS V2 API directly. They form a chain: `list_tools` and `search_tools` yield tool ids, a tool yields version names, and a version's `get_tool_files` yields the paths that `get_tool_descriptor_by_path` takes. Pass `summary` to `list_tools` or `search_tools` to get diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 4625d08..8172d8c 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -315,37 +315,35 @@ async def get_tool_version(tool_id: ToolId, version_id: VersionId) -> ToolVersio """ return ToolVersion.model_validate(await get_json(version_path(tool_id, version_id))) - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def get_tool_descriptor( - tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType - ) -> FileWrapper: - """Fetch the primary descriptor of one version: the main CWL, WDL, Nextflow, etc. file, or notebook. - - Reach for get_tool_files to see what other files the version has, and - get_tool_descriptor_by_path to fetch one of them. - - Returns: - The descriptor's content, checksum, and source URL. - """ - path = f"{version_path(tool_id, version_id)}/{descriptor_type}/descriptor" - return FileWrapper.model_validate(await get_json(path)) - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_tool_descriptor_by_path( tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType, relative_path: Annotated[ - str, - Field(description="Path of the file relative to the primary descriptor, as get_tool_files gives."), - ], + str | None, + Field( + description=( + "Path of the file relative to the primary descriptor, as get_tool_files gives. " + "Omit it to fetch the primary descriptor itself." + ) + ), + ] = None, ) -> FileWrapper: - """Fetch one file of a version by path: an imported descriptor, a config file, and so on. + """Fetch one file of a version: its primary descriptor, or any other file by path. + + Omit ``relative_path`` for the primary descriptor: the main CWL, WDL, Nextflow, + etc. file, or notebook. That needs no get_tool_files call first, so the two can + run together. Otherwise pass any path get_tool_files lists, including imported + descriptors, test parameter files, and the containerfile (e.g. Dockerfile), + which any of the version's descriptor types can fetch. Returns: The file's content, checksum, and source URL. """ - path = f"{version_path(tool_id, version_id)}/{descriptor_type}/descriptor/{_segment(relative_path)}" + path = f"{version_path(tool_id, version_id)}/{descriptor_type}/descriptor" + if relative_path is not None: + path += f"/{_segment(relative_path)}" return FileWrapper.model_validate(await get_json(path)) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) @@ -353,36 +351,10 @@ async def get_tool_files(tool_id: ToolId, version_id: VersionId, descriptor_type """List every file of one version, without their content. Use this to find a version's secondary descriptors, test parameter files, and - containerfile before fetching one. + containerfile, then fetch each with get_tool_descriptor_by_path. Returns: Each file's path and type (primary or secondary descriptor, test file, etc.). """ data = await get_json(f"{version_path(tool_id, version_id)}/{descriptor_type}/files") return [ToolFile.model_validate(item) for item in data] - - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def get_tool_tests( - tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType - ) -> list[FileWrapper]: - """Fetch the test parameter files of one version: example inputs for running it. - - Returns: - The content of every test parameter file; empty if the version has none. - """ - data = await get_json(f"{version_path(tool_id, version_id)}/{descriptor_type}/tests") - return [FileWrapper.model_validate(item) for item in data] - - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def get_tool_containerfile(tool_id: ToolId, version_id: VersionId) -> list[FileWrapper]: - """Fetch the containerfile (e.g. Dockerfile) that builds one version's image. - - Only some tools have one, typically those registered from a Docker image - rather than as a workflow; a version whose ``containerfile`` is false has - none, and the call fails. - - Returns: - The content of each containerfile. - """ - data = await get_json(f"{version_path(tool_id, version_id)}/containerfile") - return [FileWrapper.model_validate(item) for item in data] diff --git a/tests/test_server.py b/tests/test_server.py index dcfd3c1..6f7115b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -28,11 +28,8 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: "get_entry", "get_file", "get_tool", - "get_tool_containerfile", - "get_tool_descriptor", "get_tool_descriptor_by_path", "get_tool_files", - "get_tool_tests", "get_tool_version", "get_trs_info", "get_version", diff --git a/tests/test_trs.py b/tests/test_trs.py index 2a1eb4e..b91cd20 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -103,9 +103,9 @@ }, ] -TESTS_RESPONSE = [{"checksum": [], "content": '{"input": 1}', "url": "https://example.org/test.json"}] +TEST_FILE_RESPONSE = {"checksum": [], "content": '{"input": 1}', "url": "https://example.org/test.json"} -CONTAINERFILE_RESPONSE = [{"checksum": [], "content": "FROM ubuntu:24.04\n", "url": "https://example.org/Dockerfile"}] +CONTAINERFILE_RESPONSE = {"checksum": [], "content": "FROM ubuntu:24.04\n", "url": "https://example.org/Dockerfile"} _VERSION_PATH = f"/tools/{TOOL_ID}/versions/{VERSION_ID}" @@ -119,8 +119,8 @@ f"{_VERSION_PATH}/CWL/descriptor": DESCRIPTOR_RESPONSE, f"{_VERSION_PATH}/CWL/descriptor/{SECONDARY_PATH}": DESCRIPTOR_RESPONSE, f"{_VERSION_PATH}/CWL/files": FILES_RESPONSE, - f"{_VERSION_PATH}/CWL/tests": TESTS_RESPONSE, - f"{_VERSION_PATH}/containerfile": CONTAINERFILE_RESPONSE, + f"{_VERSION_PATH}/CWL/descriptor/test.json": TEST_FILE_RESPONSE, + f"{_VERSION_PATH}/CWL/descriptor/Dockerfile": CONTAINERFILE_RESPONSE, f"{_VERSION_PATH}/JUPYTER/files": [{"checksum": None, "file_type": "PRIMARY_DESCRIPTOR", "path": "main.ipynb"}], } @@ -363,13 +363,16 @@ async def test_get_tool_version(client: Client[Any], requests_made: list[httpx.R assert requests_made[0].url.raw_path.endswith(b"/versions/feature%2Fbranch") -async def test_get_tool_descriptor(client: Client[Any]) -> None: +async def test_get_tool_descriptor_by_path_defaults_to_the_primary_descriptor( + client: Client[Any], requests_made: list[httpx.Request] +) -> None: async with client: result = await client.call_tool( - "get_tool_descriptor", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL"} + "get_tool_descriptor_by_path", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL"} ) assert result.data.content.startswith("cwlVersion: v1.0") assert result.data.checksum[0].checksum == "def456" + assert requests_made[0].url.raw_path.endswith(b"/CWL/descriptor") async def test_get_tool_descriptor_by_path_encodes_the_path( @@ -396,18 +399,18 @@ async def test_get_tool_files(client: Client[Any]) -> None: assert result.data[1].checksum.checksum == "789abc" -async def test_get_tool_tests(client: Client[Any]) -> None: +@pytest.mark.parametrize( + ("relative_path", "content"), [("test.json", '{"input": 1}'), ("Dockerfile", "FROM ubuntu:24.04\n")] +) +async def test_get_tool_descriptor_by_path_fetches_tests_and_containerfiles( + client: Client[Any], relative_path: str, content: str +) -> None: async with client: result = await client.call_tool( - "get_tool_tests", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL"} + "get_tool_descriptor_by_path", + {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL", "relative_path": relative_path}, ) - assert [test.content for test in result.data] == ['{"input": 1}'] - - -async def test_get_tool_containerfile(client: Client[Any]) -> None: - async with client: - result = await client.call_tool("get_tool_containerfile", {"tool_id": TOOL_ID, "version_id": VERSION_ID}) - assert result.data[0].content == "FROM ubuntu:24.04\n" + assert result.data.content == content async def test_get_tool_files_for_a_notebook(client: Client[Any]) -> None: @@ -422,7 +425,8 @@ async def test_get_tool_rejects_unknown_descriptor_types(client: Client[Any]) -> async with client: with pytest.raises(ToolError): await client.call_tool( - "get_tool_descriptor", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "PLAIN_CWL"} + "get_tool_descriptor_by_path", + {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "PLAIN_CWL"}, ) From 35dc77b61d99864022e5c387f5b3e3acba5e806a Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Thu, 24 Sep 2026 17:36:28 -0400 Subject: [PATCH 12/21] Fold hello into get_trs_info with a local_only option hello only reported which Dockstore instance the server is attached to and its version, which is what get_trs_info is for. get_trs_info now always reports dockstore_url and server_version, and local_only=True returns just those without contacting Dockstore, keeping hello's use as a smoke test. That leaves 12 tools. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 7 +++-- README.md | 6 ++-- src/dockstore_mcp/models.py | 22 ++++++++++---- src/dockstore_mcp/tools/__init__.py | 3 +- src/dockstore_mcp/tools/hello.py | 47 ----------------------------- src/dockstore_mcp/tools/trs.py | 30 +++++++++++++----- tests/test_server.py | 17 +---------- tests/test_trs.py | 11 +++++++ 8 files changed, 58 insertions(+), 85 deletions(-) delete mode 100644 src/dockstore_mcp/tools/hello.py diff --git a/CLAUDE.md b/CLAUDE.md index 1ffdc8b..9eace6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ An MCP (Model Context Protocol) server, built on FastMCP 4, that exposes Docksto assistants. It is a standalone process deployed alongside the Dockstore webservice, talking to Dockstore's GA4GH Tool Registry Service (TRS) API and its own proprietary API. -**Status: scaffold.** Only the `hello` tool has a working body. The four Dockstore +**Status: scaffold.** The GA4GH TRS tools in `trs.py` have working bodies. The four Dockstore tools (`search_entries`, `get_entry`, `get_version`, `get_file`) are fully declared (names, arguments, response models, docstrings) but each raises `NotImplementedError` until wired up to the real Dockstore API. @@ -31,7 +31,7 @@ Run a single test with pytest directly (no Makefile target for this): ```bash .venv/bin/pytest tests/test_tools.py::test_search_takes_every_facet -.venv/bin/pytest -k "hello" +.venv/bin/pytest -k "trs_info" ``` Tests use FastMCP's in-memory `Client`/`FastMCP` pairing (see `tests/conftest.py`), so @@ -93,7 +93,8 @@ back from the installed package's metadata at runtime (`importlib.metadata.versi - `src/dockstore_mcp/tools/` — one module per cohesive tool group, each exposing `register(mcp: FastMCP, settings: Settings) -> None`. `tools/__init__.py`'s `register_all` calls each in turn; new tool modules must be added there. - - `hello.py` — smoke-test tool, implemented. + - `trs.py` — the GA4GH TRS tools, implemented. `get_trs_info(local_only=True)` doubles as a + smoke test that never contacts Dockstore. - `search.py` — `search_entries`, the Dockstore Search page equivalent. - `entries.py` — `get_entry` → `get_version` → `get_file`, a lookup chain: an entry's `version_ids` feed `get_version`, whose `file_paths` feed `get_file`. diff --git a/README.md b/README.md index 36fc31c..d48ff1b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ MCP, so it is deployed alongside the Dockstore webservice rather than inside it. It is built on [FastMCP](https://gofastmcp.com) 4 and ships as a container image. -> **Status: scaffold.** `hello` and the GA4GH TRS tools (`get_trs_info` through +> **Status: scaffold.** The GA4GH TRS tools (`get_trs_info` through > `get_tool_descriptor_by_path`) have working bodies; the other four Dockstore tools are > declared — names, arguments, and response shapes — but each one raises > `NotImplementedError` until it is wired up to the Dockstore API. @@ -129,8 +129,7 @@ the full list. | Tool | Description | | ----------------------------- | ------------------------------------------------------------------------------------ | -| `hello` | Greets the caller and reports the Dockstore instance and server version. No I/O. | -| `get_trs_info` | Describes this instance's TRS API: identifiers, version, operator, and tool classes. | +| `get_trs_info` | Reports instance and version; unless `local_only`, also TRS info and tool classes. | | `list_tools` | Lists one page of every tool and workflow the TRS API serves, with the total count. | | `search_tools` | Finds TRS tools by name, organization, author, class, descriptor language, etc. | | `get_tool` | Retrieves one TRS tool by id, including all of its versions. | @@ -166,7 +165,6 @@ src/dockstore_mcp/ └── tools/ ├── __init__.py registers every tool group ├── entries.py get_entry, get_version, get_file - ├── hello.py the hello tool ├── search.py search_entries └── trs.py get_trs_info and the other GA4GH TRS tools tests/ pytest suite, using FastMCP's in-memory client diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index b27090c..9536877 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -201,13 +201,23 @@ class ServiceOrganization(BaseModel): class TrsInfo(BaseModel): - """GA4GH TRS service-info, plus the tool classes the service sorts entries into.""" + """The Dockstore instance this server talks to and, unless only local details were asked for, its TRS service-info. - id: str = Field(description="Unique identifier of this service, in reverse domain name notation.") - name: str = Field(description="Human-readable name of this service.") - type: ServiceType = Field(description="Which GA4GH API this service implements, and at what version.") - organization: ServiceOrganization = Field(description="Organization operating this service.") - version: str = Field(description="Version of the service software.") + Only ``dockstore_url`` and ``server_version`` are filled in when ``get_trs_info`` is + asked not to contact Dockstore. + """ + + dockstore_url: str = Field(description="The Dockstore instance this server is configured to talk to.") + server_version: str = Field(description="Version of the dockstore-mcp package that answered.") + id: str | None = Field( + default=None, description="Unique identifier of this service, in reverse domain name notation." + ) + name: str | None = Field(default=None, description="Human-readable name of this service.") + type: ServiceType | None = Field( + default=None, description="Which GA4GH API this service implements, and at what version." + ) + organization: ServiceOrganization | None = Field(default=None, description="Organization operating this service.") + version: str | None = Field(default=None, description="Version of the service software.") description: str | None = Field(default=None, description="Human-readable description of the service.") contact_url: str | None = Field(default=None, description="Contact URL or mailto link for the service.") documentation_url: str | None = Field(default=None, description="URL of the service's documentation.") diff --git a/src/dockstore_mcp/tools/__init__.py b/src/dockstore_mcp/tools/__init__.py index ec9ca0c..6c860dd 100644 --- a/src/dockstore_mcp/tools/__init__.py +++ b/src/dockstore_mcp/tools/__init__.py @@ -21,14 +21,13 @@ from fastmcp import FastMCP from dockstore_mcp.config import Settings -from dockstore_mcp.tools import entries, hello, search, trs +from dockstore_mcp.tools import entries, search, trs __all__ = ["register_all"] def register_all(mcp: FastMCP, settings: Settings) -> None: """Register every tool this server provides.""" - hello.register(mcp, settings) search.register(mcp, settings) entries.register(mcp, settings) trs.register(mcp, settings) diff --git a/src/dockstore_mcp/tools/hello.py b/src/dockstore_mcp/tools/hello.py deleted file mode 100644 index 158acf1..0000000 --- a/src/dockstore_mcp/tools/hello.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright 2026 OICR and UCSC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""A minimal tool, useful as an end-to-end smoke test of a deployment.""" - -from fastmcp import FastMCP -from pydantic import BaseModel, Field - -from dockstore_mcp import __version__ -from dockstore_mcp.config import Settings - -__all__ = ["Greeting", "register"] - - -class Greeting(BaseModel): - """The result of a call to the ``hello`` tool.""" - - greeting: str = Field(description="A friendly greeting.") - dockstore_url: str = Field(description="The Dockstore instance this server is configured to talk to.") - server_version: str = Field(description="Version of the dockstore-mcp package that answered.") - - -def register(mcp: FastMCP, settings: Settings) -> None: - """Add the hello tool to ``mcp``.""" - - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False}) - def hello(name: str = "world") -> Greeting: - """Greet someone and report which Dockstore instance this server is attached to. - - Use this to confirm that the Dockstore MCP server is reachable and correctly - configured. It does not contact Dockstore itself. - """ - return Greeting( - greeting=f"Hello, {name}!", - dockstore_url=settings.dockstore_url, - server_version=__version__, - ) diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 8172d8c..ea28b49 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -30,6 +30,7 @@ from fastmcp import FastMCP from pydantic import Field +from dockstore_mcp import __version__ from dockstore_mcp.casing import normalize_keys from dockstore_mcp.config import Settings from dockstore_mcp.models import ( @@ -180,21 +181,36 @@ def version_path(tool_id: str, version_id: str) -> str: return f"/tools/{_segment(tool_id)}/versions/{_segment(version_id)}" @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def get_trs_info() -> TrsInfo: - """Describe this Dockstore instance's GA4GH Tool Registry Service (TRS) API. + async def get_trs_info( + local_only: Annotated[ + bool, + Field( + description=( + "Report only which Dockstore instance this server is attached to and the server's version, " + "without contacting Dockstore: a quick check that the server is up and configured." + ) + ), + ] = False, + ) -> TrsInfo: + """Describe the Dockstore instance this server is attached to, and its GA4GH Tool Registry Service (TRS) API. Use this to identify which Dockstore instance a server is talking to, which version of the TRS API it implements, and who operates it, and to see which tool classes (for example 'Workflow' or 'CommandLineTool') it sorts entries - into, e.g. before filtering search_tools by one. It takes no arguments and - always describes the ``dockstore_url`` this server is configured with. + into, e.g. before filtering search_tools by one. It always describes the + ``dockstore_url`` this server is configured with. Pass ``local_only`` to + confirm the server is reachable and configured without calling Dockstore. Returns: - Service metadata: identifiers, the TRS API version implemented, the - organization operating the service, and every tool class it recognizes. + The Dockstore URL and server version, plus (unless ``local_only``) the + service's identifiers, the TRS API version implemented, the organization + operating it, and every tool class it recognizes. """ + local = {"dockstore_url": settings.dockstore_url, "server_version": __version__} + if local_only: + return TrsInfo.model_validate(local) service_info, tool_classes = await asyncio.gather(get("/service-info"), get("/toolClasses")) - info = TrsInfo.model_validate(normalize_keys(service_info.json())) + info = TrsInfo.model_validate({**normalize_keys(service_info.json()), **local}) info.tool_classes = [ToolClass.model_validate(item) for item in normalize_keys(tool_classes.json())] return info diff --git a/tests/test_server.py b/tests/test_server.py index 6f7115b..668b0a4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for server construction and the hello tool.""" +"""Tests for server construction.""" from typing import Any @@ -33,7 +33,6 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: "get_tool_version", "get_trs_info", "get_version", - "hello", "list_tool_versions", "list_tools", "search_entries", @@ -41,20 +40,6 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: ] -async def test_hello_greets_by_name(client: Client[Any]) -> None: - async with client: - result = await client.call_tool("hello", {"name": "Dockstore"}) - assert result.data.greeting == "Hello, Dockstore!" - assert result.data.dockstore_url == "https://staging.dockstore.org" - assert result.data.server_version == __version__ - - -async def test_hello_has_a_default_name(client: Client[Any]) -> None: - async with client: - result = await client.call_tool("hello", {}) - assert result.data.greeting == "Hello, world!" - - def test_health_endpoint(server: FastMCP) -> None: with TestClient(server.http_app()) as http: response = http.get("/health") diff --git a/tests/test_trs.py b/tests/test_trs.py index b91cd20..777b83e 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -197,6 +197,17 @@ async def test_get_trs_info(client: Client[Any]) -> None: assert result.data.contact_url == "mailto:support@dockstore.org" assert [tool_class.id for tool_class in result.data.tool_classes] == ["CommandLineTool", "Workflow"] assert result.data.tool_classes[1].name == "Workflow" + assert result.data.dockstore_url == "https://staging.dockstore.org" + assert result.data.server_version == __version__ + + +async def test_get_trs_info_local_only_skips_dockstore(client: Client[Any], requests_made: list[httpx.Request]) -> None: + async with client: + result = await client.call_tool("get_trs_info", {"local_only": True}) + assert result.data.dockstore_url == "https://staging.dockstore.org" + assert result.data.server_version == __version__ + assert (result.data.id, result.data.tool_classes) == (None, []) + assert requests_made == [] @pytest.mark.parametrize("path", ["/service-info", "/toolClasses"]) From 90eb58c7722aa3ed1e1b29e6e7acee7a8c27bc92 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 10:23:36 -0400 Subject: [PATCH 13/21] Fold get_tool_files into get_tool_version get_tool_version takes an optional files descriptor type: given one, it fetches the version and its file listing in parallel and returns them together, so the tool list drops from 12 to 11. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++--- src/dockstore_mcp/models.py | 8 +++++ src/dockstore_mcp/tools/trs.py | 54 ++++++++++++++++++++-------------- tests/test_server.py | 1 - tests/test_trs.py | 17 ++++++----- 5 files changed, 53 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index d48ff1b..2a898cb 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,8 @@ the full list. | `search_tools` | Finds TRS tools by name, organization, author, class, descriptor language, etc. | | `get_tool` | Retrieves one TRS tool by id, including all of its versions. | | `list_tool_versions` | Lists every version of one TRS tool. | -| `get_tool_version` | Retrieves one version of a TRS tool: authors, images, descriptor languages. | -| `get_tool_files` | Lists every file of a version (descriptors, tests, containerfile), without content. | -| `get_tool_descriptor_by_path` | Fetches a version's primary descriptor, or any file get_tool_files lists, by path. | +| `get_tool_version` | Retrieves one version of a TRS tool: authors, images, languages, optionally files. | +| `get_tool_descriptor_by_path` | Fetches a version's primary descriptor, or any file get_tool_version lists, by path. | | `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | | `get_entry` | Retrieves the requested fields of one entry. | | `get_version` | Retrieves the requested fields of one version of an entry. | @@ -144,7 +143,7 @@ the full list. The TRS tools, from `get_trs_info` to `get_tool_descriptor_by_path`, call Dockstore's GA4GH TRS V2 API directly. They form a chain: `list_tools` and `search_tools` yield tool ids, a -tool yields version names, and a version's `get_tool_files` yields the paths that +tool yields version names, and `get_tool_version` with `files` yields the paths that `get_tool_descriptor_by_path` takes. Pass `summary` to `list_tools` or `search_tools` to get each tool's id, languages, and version names without its full README and version details. diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 9536877..991bb04 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -379,6 +379,14 @@ class ToolFile(BaseModel): checksum: Checksum | None = Field(default=None, description="Checksum of the file.") +class ToolVersionWithFiles(ToolVersion): + """One version of a GA4GH TRS tool, optionally with its file listing.""" + + files: list[ToolFile] | None = Field( + default=None, description="Every file of this version in the requested descriptor language, if asked for." + ) + + class EntryField(StrEnum): """Fields of an :class:`Entry` that get_entry can return.""" diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index ea28b49..5822fe9 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -18,8 +18,8 @@ Dockstore API: every one is an unauthenticated GET against the TRS V2 API. The lookups form a chain: ``list_tools``/``search_tools`` yield tool ids, -``get_tool``/``list_tool_versions`` yield version names, and a version's -``get_tool_files`` listing yields the paths ``get_tool_descriptor_by_path`` takes. +``get_tool``/``list_tool_versions`` yield version names, and ``get_tool_version``'s +file listing yields the paths ``get_tool_descriptor_by_path`` takes. """ import asyncio @@ -42,6 +42,7 @@ ToolSummary, ToolVersion, ToolVersionSummary, + ToolVersionWithFiles, TrsDescriptorType, TrsInfo, ) @@ -323,13 +324,35 @@ async def list_tool_versions( return versions @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def get_tool_version(tool_id: ToolId, version_id: VersionId) -> ToolVersion: - """Retrieve one version of a tool or workflow: its authors, container images, and languages. + async def get_tool_version( + tool_id: ToolId, + version_id: VersionId, + files: Annotated[ + TrsDescriptorType | None, + Field( + description=( + "Also list every file of the version in this descriptor language, without their content. " + "Omit it for just the version's metadata." + ) + ), + ] = None, + ) -> ToolVersionWithFiles: + """Retrieve one version of a tool or workflow: its authors, container images, languages, and optionally files. + + Pass ``files`` to also list the version's secondary descriptors, test parameter + files, and containerfile, then fetch each with get_tool_descriptor_by_path. Returns: - The version's metadata. + The version's metadata and, if ``files`` is given, each file's path and type + (primary or secondary descriptor, test file, etc.). """ - return ToolVersion.model_validate(await get_json(version_path(tool_id, version_id))) + path = version_path(tool_id, version_id) + if files is None: + return ToolVersionWithFiles.model_validate(await get_json(path)) + version, file_list = await asyncio.gather(get_json(path), get_json(f"{path}/{files}/files")) + return ToolVersionWithFiles.model_validate( + {**version, "files": [ToolFile.model_validate(item) for item in file_list]} + ) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_tool_descriptor_by_path( @@ -340,7 +363,7 @@ async def get_tool_descriptor_by_path( str | None, Field( description=( - "Path of the file relative to the primary descriptor, as get_tool_files gives. " + "Path of the file relative to the primary descriptor, as get_tool_version's files give. " "Omit it to fetch the primary descriptor itself." ) ), @@ -349,8 +372,8 @@ async def get_tool_descriptor_by_path( """Fetch one file of a version: its primary descriptor, or any other file by path. Omit ``relative_path`` for the primary descriptor: the main CWL, WDL, Nextflow, - etc. file, or notebook. That needs no get_tool_files call first, so the two can - run together. Otherwise pass any path get_tool_files lists, including imported + etc. file, or notebook. That needs no get_tool_version call first, so the two can + run together. Otherwise pass any path get_tool_version's files list, including imported descriptors, test parameter files, and the containerfile (e.g. Dockerfile), which any of the version's descriptor types can fetch. @@ -361,16 +384,3 @@ async def get_tool_descriptor_by_path( if relative_path is not None: path += f"/{_segment(relative_path)}" return FileWrapper.model_validate(await get_json(path)) - - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def get_tool_files(tool_id: ToolId, version_id: VersionId, descriptor_type: DescriptorType) -> list[ToolFile]: - """List every file of one version, without their content. - - Use this to find a version's secondary descriptors, test parameter files, and - containerfile, then fetch each with get_tool_descriptor_by_path. - - Returns: - Each file's path and type (primary or secondary descriptor, test file, etc.). - """ - data = await get_json(f"{version_path(tool_id, version_id)}/{descriptor_type}/files") - return [ToolFile.model_validate(item) for item in data] diff --git a/tests/test_server.py b/tests/test_server.py index 668b0a4..a6edebd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -29,7 +29,6 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: "get_file", "get_tool", "get_tool_descriptor_by_path", - "get_tool_files", "get_tool_version", "get_trs_info", "get_version", diff --git a/tests/test_trs.py b/tests/test_trs.py index 777b83e..1f1ab86 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -372,6 +372,8 @@ async def test_get_tool_version(client: Client[Any], requests_made: list[httpx.R assert result.data.images[0].checksum[0].type == "sha-256" assert result.data.descriptor_type_version == {"CWL": ["v1.0"]} assert requests_made[0].url.raw_path.endswith(b"/versions/feature%2Fbranch") + assert result.data.files is None + assert len(requests_made) == 1 async def test_get_tool_descriptor_by_path_defaults_to_the_primary_descriptor( @@ -398,16 +400,17 @@ async def test_get_tool_descriptor_by_path_encodes_the_path( assert requests_made[0].url.raw_path.endswith(b"/CWL/descriptor/..%2Ftools%2Fstep.cwl") -async def test_get_tool_files(client: Client[Any]) -> None: +async def test_get_tool_version_with_files(client: Client[Any]) -> None: async with client: result = await client.call_tool( - "get_tool_files", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL"} + "get_tool_version", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "files": "CWL"} ) - assert [(file.path, file.file_type) for file in result.data] == [ + assert result.data.author == ["Jane Doe"] + assert [(file.path, file.file_type) for file in result.data.files] == [ ("main.cwl", "PRIMARY_DESCRIPTOR"), (SECONDARY_PATH, "SECONDARY_DESCRIPTOR"), ] - assert result.data[1].checksum.checksum == "789abc" + assert result.data.files[1].checksum.checksum == "789abc" @pytest.mark.parametrize( @@ -424,12 +427,12 @@ async def test_get_tool_descriptor_by_path_fetches_tests_and_containerfiles( assert result.data.content == content -async def test_get_tool_files_for_a_notebook(client: Client[Any]) -> None: +async def test_get_tool_version_with_files_for_a_notebook(client: Client[Any]) -> None: async with client: result = await client.call_tool( - "get_tool_files", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "JUPYTER"} + "get_tool_version", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "files": "JUPYTER"} ) - assert [file.path for file in result.data] == ["main.ipynb"] + assert [file.path for file in result.data.files] == ["main.ipynb"] async def test_get_tool_rejects_unknown_descriptor_types(client: Client[Any]) -> None: From 71c36d3af8a4851843cb6f4542d3e06843e877dd Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 10:27:16 -0400 Subject: [PATCH 14/21] Fold search_tools into list_tools list_tools takes search_tools' filters: with none it pages through every tool as before. The tool list drops from 11 to 10, and since each tool carried its own copy of the ToolPage output schema, the schemas shrink by about 9 KB. A non-TRS search will come separately. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++--- src/dockstore_mcp/models.py | 2 +- src/dockstore_mcp/tools/trs.py | 40 ++++++++++++---------------------- tests/test_server.py | 1 - tests/test_trs.py | 8 +++---- 5 files changed, 22 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 2a898cb..4b435c8 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,7 @@ the full list. | Tool | Description | | ----------------------------- | ------------------------------------------------------------------------------------ | | `get_trs_info` | Reports instance and version; unless `local_only`, also TRS info and tool classes. | -| `list_tools` | Lists one page of every tool and workflow the TRS API serves, with the total count. | -| `search_tools` | Finds TRS tools by name, organization, author, class, descriptor language, etc. | +| `list_tools` | Lists one page of TRS tools, optionally filtered by name, class, language, etc. | | `get_tool` | Retrieves one TRS tool by id, including all of its versions. | | `list_tool_versions` | Lists every version of one TRS tool. | | `get_tool_version` | Retrieves one version of a TRS tool: authors, images, languages, optionally files. | @@ -142,9 +141,9 @@ the full list. | `get_file` | Retrieves the requested fields of one file belonging to a version. | The TRS tools, from `get_trs_info` to `get_tool_descriptor_by_path`, call Dockstore's GA4GH -TRS V2 API directly. They form a chain: `list_tools` and `search_tools` yield tool ids, a +TRS V2 API directly. They form a chain: `list_tools` yields tool ids, a tool yields version names, and `get_tool_version` with `files` yields the paths that -`get_tool_descriptor_by_path` takes. Pass `summary` to `list_tools` or `search_tools` to get +`get_tool_descriptor_by_path` takes. Pass `summary` to `list_tools` to get each tool's id, languages, and version names without its full README and version details. The last four are scaffolding and are not implemented yet. They are a chain too: diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 991bb04..b5b4e04 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -228,7 +228,7 @@ class TrsInfo(BaseModel): updated_at: datetime | None = Field(default=None, description="When the service was last updated.") tool_classes: list["ToolClass"] = Field( default_factory=list, - description="Every tool class (e.g. 'Workflow') the service sorts entries into; search_tools filters by these.", + description="Every tool class (e.g. 'Workflow') the service sorts entries into; list_tools filters by these.", ) diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 5822fe9..d2400c8 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -17,7 +17,7 @@ Unlike the tools in ``entries.py`` and ``search.py``, these are wired up to the real Dockstore API: every one is an unauthenticated GET against the TRS V2 API. -The lookups form a chain: ``list_tools``/``search_tools`` yield tool ids, +The lookups form a chain: ``list_tools`` yields tool ids, ``get_tool``/``list_tool_versions`` yield version names, and ``get_tool_version``'s file listing yields the paths ``get_tool_descriptor_by_path`` takes. """ @@ -52,7 +52,7 @@ #: How long to wait for the Dockstore TRS API to respond. REQUEST_TIMEOUT = 30.0 -#: How many tools list_tools and search_tools return per page unless asked for more. +#: How many tools list_tools returns per page unless asked for more. #: The TRS default of 1000 would flood a model's context: every tool carries all of #: its versions. DEFAULT_PAGE_SIZE = 20 @@ -66,7 +66,7 @@ ToolId = Annotated[ str, - Field(description="TRS tool id, e.g. '#workflow/github.com/org/repo/name', as list_tools or search_tools give."), + Field(description="TRS tool id, e.g. '#workflow/github.com/org/repo/name', as list_tools gives."), ] VersionId = Annotated[str, Field(description="Version name, e.g. 'master' or '1.0', as list_tool_versions gives.")] DescriptorType = Annotated[TrsDescriptorType, Field(description="Descriptor language of the files to fetch.")] @@ -198,7 +198,7 @@ async def get_trs_info( Use this to identify which Dockstore instance a server is talking to, which version of the TRS API it implements, and who operates it, and to see which tool classes (for example 'Workflow' or 'CommandLineTool') it sorts entries - into, e.g. before filtering search_tools by one. It always describes the + into, e.g. before filtering list_tools by one. It always describes the ``dockstore_url`` this server is configured with. Pass ``local_only`` to confirm the server is reachable and configured without calling Dockstore. @@ -216,23 +216,7 @@ async def get_trs_info( return info @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def list_tools(limit: Limit = DEFAULT_PAGE_SIZE, offset: Offset = 0, summary: Summary = False) -> ToolPage: - """List one page of every tool and workflow this Dockstore instance's TRS API serves. - - Reach for search_tools instead to narrow the list by name, language, class, - or other filters; this one pages through everything. The page's ``total`` - says how many tools there are in all, so to count them, ask for one page - with ``limit`` 1. Fetch the next page by passing ``next_offset`` as - ``offset`` (a page number, not an item index); it is unset on the last page. - - Returns: - Up to ``limit`` tools, each with all of its versions (or summarized, if ``summary``), plus the - total and next page's offset. - """ - return await get_tool_page({}, limit, offset, summary) - - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def search_tools( + async def list_tools( name: Annotated[str | None, Field(description="Match against the tool's repository path, e.g. 'gatk'.")] = None, toolname: Annotated[str | None, Field(description="Match against the tool or workflow's own name.")] = None, description: Annotated[str | None, Field(description="Match against the tool's description.")] = None, @@ -257,11 +241,15 @@ async def search_tools( offset: Offset = 0, summary: Summary = False, ) -> ToolPage: - """Find tools and workflows through the TRS API by name, language, class, and other filters. - - Every filter given must match; text filters match substrings. Page through - the results, or count them, as with list_tools. For richer keyword search - with facets, the Dockstore Search page equivalent is search_entries. + """List one page of the tools and workflows this Dockstore instance's TRS API serves, optionally filtered. + + With no filters this pages through everything; narrow it by name, language, + class, and other filters. Every filter given must match; text filters match + substrings. The page's ``total`` says how many tools match in all, so to + count them, ask for one page with ``limit`` 1. Fetch the next page by passing + ``next_offset`` as ``offset`` (a page number, not an item index); it is unset + on the last page. For richer keyword search with facets, the Dockstore + Search page equivalent is search_entries. Returns: Up to ``limit`` matching tools, each with all of its versions (or summarized, if ``summary``), plus diff --git a/tests/test_server.py b/tests/test_server.py index a6edebd..f63bc34 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -35,7 +35,6 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: "list_tool_versions", "list_tools", "search_entries", - "search_tools", ] diff --git a/tests/test_trs.py b/tests/test_trs.py index 1f1ab86..5781e85 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -303,9 +303,9 @@ async def test_summary_caps_version_names(client: Client[Any], _mock_trs_api: di assert (summary["version_count"], summary["versions_truncated"]) == (30, True) -async def test_search_tools_summarizes(client: Client[Any]) -> None: +async def test_list_tools_filters_and_summarizes(client: Client[Any]) -> None: async with client: - result = await client.call_tool("search_tools", {"toolname": "name", "summary": True, "limit": 5}) + result = await client.call_tool("list_tools", {"toolname": "name", "summary": True, "limit": 5}) page = result.structured_content assert page is not None assert [tool["id"] for tool in page["tools"]] == [tool["id"] for tool in CATALOG[:5]] @@ -326,10 +326,10 @@ async def test_list_tools_defaults_to_a_small_page(client: Client[Any], requests assert dict(requests_made[0].url.params) == {"limit": "20", "offset": "0"} -async def test_search_tools_sends_only_given_filters(client: Client[Any], requests_made: list[httpx.Request]) -> None: +async def test_list_tools_sends_only_given_filters(client: Client[Any], requests_made: list[httpx.Request]) -> None: async with client: result = await client.call_tool( - "search_tools", + "list_tools", {"toolname": "name", "tool_class": "Workflow", "descriptor_type": "NFL", "checker": False, "limit": 5}, ) assert result.data.total == 7 From 304d4627add3600f086bbcd39f07c16aef94e25e Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 10:43:37 -0400 Subject: [PATCH 15/21] Fold list_tool_versions into get_tool get_tool takes list_tool_versions' summary option, which shrinks each version to its name, meta_version, and is_production. Dockstore's /tools/{id} already returns every version, so this needs no extra request, and the tool list drops from 10 to 9. get_tool returns a new ToolDetail, whose versions are either full or summarized, rather than a union of models, which FastMCP would wrap under "result". Also export ToolVersionWithFiles from models. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +-- src/dockstore_mcp/models.py | 21 ++++++++++++++++--- src/dockstore_mcp/tools/trs.py | 37 +++++++++++++--------------------- tests/test_server.py | 1 - tests/test_trs.py | 17 +++++++++------- 5 files changed, 43 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 4b435c8..ca03cb8 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,7 @@ the full list. | ----------------------------- | ------------------------------------------------------------------------------------ | | `get_trs_info` | Reports instance and version; unless `local_only`, also TRS info and tool classes. | | `list_tools` | Lists one page of TRS tools, optionally filtered by name, class, language, etc. | -| `get_tool` | Retrieves one TRS tool by id, including all of its versions. | -| `list_tool_versions` | Lists every version of one TRS tool. | +| `get_tool` | Retrieves one TRS tool by id, with all of its versions in full or summarized. | | `get_tool_version` | Retrieves one version of a TRS tool: authors, images, languages, optionally files. | | `get_tool_descriptor_by_path` | Fetches a version's primary descriptor, or any file get_tool_version lists, by path. | | `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index b5b4e04..350f24d 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -44,11 +44,13 @@ "SortOrder", "Tool", "ToolClass", + "ToolDetail", "ToolFile", "ToolPage", "ToolSummary", "ToolVersion", "ToolVersionSummary", + "ToolVersionWithFiles", "TrsDescriptorType", "TrsInfo", "Version", @@ -301,8 +303,8 @@ class ToolVersionSummary(BaseModel): 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.""" +class _ToolBase(BaseModel): + """The fields of a GA4GH TRS tool other than its versions, shared by :class:`Tool` and :class:`ToolDetail`.""" id: str | None = Field(default=None, description="TRS identifier of the tool; pass this as tool_id.") url: str | None = Field(default=None, description="TRS API URL of the tool.") @@ -314,9 +316,22 @@ class Tool(BaseModel): meta_version: str | None = Field(default=None, description="Revision of this tool's metadata.") has_checker: bool | None = Field(default=None, description="Whether the tool has a checker workflow.") checker_url: str | None = Field(default=None, description="TRS URL of the checker workflow, if any.") + + +class Tool(_ToolBase): + """A GA4GH TRS tool: a Dockstore tool, workflow, or other entry as the TRS API describes it.""" + versions: list[ToolVersion] | None = Field(default=None, description="Every version of the tool.") +class ToolDetail(_ToolBase): + """A GA4GH TRS tool as get_tool returns it, with every version in full or summarized.""" + + versions: list[ToolVersion] | list[ToolVersionSummary] | None = Field( + default=None, description="Every version of the tool, in full or (if summarized) just its name and status." + ) + + class ToolSummary(BaseModel): """The handful of fields that identify a TRS tool in a list, without its README or version details.""" @@ -331,7 +346,7 @@ class ToolSummary(BaseModel): 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." + "Use get_tool for the rest." ), ) version_count: int = Field(default=0, description="How many versions the tool has in all.") diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index d2400c8..acca059 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -18,7 +18,7 @@ Dockstore API: every one is an unauthenticated GET against the TRS V2 API. The lookups form a chain: ``list_tools`` yields tool ids, -``get_tool``/``list_tool_versions`` yield version names, and ``get_tool_version``'s +``get_tool`` yields version names, and ``get_tool_version``'s file listing yields the paths ``get_tool_descriptor_by_path`` takes. """ @@ -37,6 +37,7 @@ FileWrapper, Tool, ToolClass, + ToolDetail, ToolFile, ToolPage, ToolSummary, @@ -68,7 +69,7 @@ str, Field(description="TRS tool id, e.g. '#workflow/github.com/org/repo/name', as list_tools gives."), ] -VersionId = Annotated[str, Field(description="Version name, e.g. 'master' or '1.0', as list_tool_versions gives.")] +VersionId = Annotated[str, Field(description="Version name, e.g. 'master' or '1.0', as get_tool gives.")] DescriptorType = Annotated[TrsDescriptorType, Field(description="Descriptor language of the files to fetch.")] Limit = Annotated[int, Field(ge=1, le=1000, description="Most tools to return in this page.")] Summary = Annotated[ @@ -271,16 +272,7 @@ async def list_tools( return await get_tool_page(params, limit, offset, summary) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def get_tool(tool_id: ToolId) -> Tool: - """Retrieve one tool or workflow by its TRS id, including every one of its versions. - - Returns: - The tool's metadata and its full list of versions. - """ - return Tool.model_validate(await get_json(f"/tools/{_segment(tool_id)}")) - - @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) - async def list_tool_versions( + async def get_tool( tool_id: ToolId, summary: Annotated[ bool, @@ -291,25 +283,24 @@ async def list_tool_versions( ) ), ] = False, - ) -> list[ToolVersion] | list[ToolVersionSummary]: - """List every version of one tool or workflow. + ) -> ToolDetail: + """Retrieve one tool or workflow by its TRS id, including every one of its versions. - 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. + Each version's ``name`` is what the 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, in full or (if ``summary``) summarized. + The tool's metadata and every one of its versions, in full or (if ``summary``) summarized. """ - data = await get_json(f"/tools/{_segment(tool_id)}/versions") - 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 + tool = Tool.model_validate(await get_json(f"/tools/{_segment(tool_id)}")) + versions: list[ToolVersion] | list[ToolVersionSummary] | None = tool.versions + if summary and tool.versions is not None: + versions = [ToolVersionSummary.model_validate(version, from_attributes=True) for version in tool.versions] + return ToolDetail.model_validate({**dict(tool), "versions": versions}) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_tool_version( diff --git a/tests/test_server.py b/tests/test_server.py index f63bc34..ce1b843 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -32,7 +32,6 @@ async def test_every_tool_is_advertised(client: Client[Any]) -> None: "get_tool_version", "get_trs_info", "get_version", - "list_tool_versions", "list_tools", "search_entries", ] diff --git a/tests/test_trs.py b/tests/test_trs.py index 5781e85..7e5aac6 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -114,7 +114,6 @@ "/service-info": SERVICE_INFO_RESPONSE, "/toolClasses": TOOL_CLASSES_RESPONSE, f"/tools/{TOOL_ID}": TOOL_RESPONSE, - f"/tools/{TOOL_ID}/versions": [VERSION_RESPONSE], _VERSION_PATH: VERSION_RESPONSE, f"{_VERSION_PATH}/CWL/descriptor": DESCRIPTOR_RESPONSE, f"{_VERSION_PATH}/CWL/descriptor/{SECONDARY_PATH}": DESCRIPTOR_RESPONSE, @@ -349,17 +348,21 @@ async def test_get_tool_encodes_the_id(client: Client[Any], requests_made: list[ assert requests_made[0].url.raw_path.endswith(b"/tools/%23workflow%2Fgithub.com%2Forg%2Frepo%2Fname") -async def test_list_tool_versions(client: Client[Any]) -> None: +async def test_get_tool_returns_full_versions_by_default(client: Client[Any]) -> None: async with client: - result = await client.call_tool("list_tool_versions", {"tool_id": TOOL_ID}) - assert [version.name for version in result.data] == [VERSION_ID] + result = await client.call_tool("get_tool", {"tool_id": TOOL_ID}) + assert result.structured_content is not None + [version] = result.structured_content["versions"] + assert version["name"] == VERSION_ID + assert version["images"][0]["registry_host"] == "quay.io" -async def test_list_tool_versions_summarizes(client: Client[Any]) -> None: +async def test_get_tool_summarizes_versions(client: Client[Any]) -> None: async with client: - result = await client.call_tool("list_tool_versions", {"tool_id": TOOL_ID, "summary": True}) + result = await client.call_tool("get_tool", {"tool_id": TOOL_ID, "summary": True}) assert result.structured_content is not None - [version] = result.structured_content["result"] + assert result.structured_content["organization"] == "org" + [version] = result.structured_content["versions"] assert set(version) == {"name", "meta_version", "is_production"} assert version["name"] == VERSION_ID From 5efb5ce8b8e87372b6b5cb708f778d668787f6c2 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 10:50:42 -0400 Subject: [PATCH 16/21] Keep a version's metadata when its file listing fails get_tool_version with files used to fail outright if only the file listing request failed. It now returns the version with files unset and a files_error saying why; a missing version still fails the call. Co-Authored-By: Claude Opus 5.5 --- src/dockstore_mcp/models.py | 3 +++ src/dockstore_mcp/tools/trs.py | 17 ++++++++++++----- tests/test_trs.py | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 350f24d..3ae9f27 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -400,6 +400,9 @@ class ToolVersionWithFiles(ToolVersion): files: list[ToolFile] | None = Field( default=None, description="Every file of this version in the requested descriptor language, if asked for." ) + files_error: str | None = Field( + default=None, description="Why the file listing could not be fetched, if it was asked for and failed." + ) class EntryField(StrEnum): diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index acca059..81e6b48 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -323,15 +323,22 @@ async def get_tool_version( Returns: The version's metadata and, if ``files`` is given, each file's path and type - (primary or secondary descriptor, test file, etc.). + (primary or secondary descriptor, test file, etc.). If only the file listing + fails, the metadata still comes back, with ``files_error`` saying why. """ path = version_path(tool_id, version_id) if files is None: return ToolVersionWithFiles.model_validate(await get_json(path)) - version, file_list = await asyncio.gather(get_json(path), get_json(f"{path}/{files}/files")) - return ToolVersionWithFiles.model_validate( - {**version, "files": [ToolFile.model_validate(item) for item in file_list]} - ) + + async def list_files() -> dict[str, Any]: + try: + data = await get_json(f"{path}/{files}/files") + except httpx.HTTPError as error: + return {"files_error": f"Could not list the version's {files} files: {error}"} + return {"files": [ToolFile.model_validate(item) for item in data]} + + version, file_listing = await asyncio.gather(get_json(path), list_files()) + return ToolVersionWithFiles.model_validate({**version, **file_listing}) @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_tool_descriptor_by_path( diff --git a/tests/test_trs.py b/tests/test_trs.py index 7e5aac6..8b66bf6 100644 --- a/tests/test_trs.py +++ b/tests/test_trs.py @@ -430,6 +430,30 @@ async def test_get_tool_descriptor_by_path_fetches_tests_and_containerfiles( assert result.data.content == content +async def test_get_tool_version_keeps_the_metadata_when_the_file_listing_fails( + client: Client[Any], _mock_trs_api: dict[str, httpx.Response] +) -> None: + _mock_trs_api[f"{_VERSION_PATH}/CWL/files"] = httpx.Response(500) + + async with client: + result = await client.call_tool( + "get_tool_version", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "files": "CWL"} + ) + assert result.data.author == ["Jane Doe"] + assert result.data.files is None + assert "500" in result.data.files_error + + +async def test_get_tool_version_surfaces_a_missing_version( + client: Client[Any], _mock_trs_api: dict[str, httpx.Response] +) -> None: + _mock_trs_api[_VERSION_PATH] = httpx.Response(404) + + async with client: + with pytest.raises(ToolError): + await client.call_tool("get_tool_version", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "files": "CWL"}) + + async def test_get_tool_version_with_files_for_a_notebook(client: Client[Any]) -> None: async with client: result = await client.call_tool( From d161d1ff1cd584f7c97977d99b6f80077b7b8f0a Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 10:54:17 -0400 Subject: [PATCH 17/21] Mark which tools are implemented in the README's tool table Co-Authored-By: Claude Opus 5.5 --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ca03cb8..b2bc980 100644 --- a/README.md +++ b/README.md @@ -127,17 +127,17 @@ the full list. ## Tools -| Tool | Description | -| ----------------------------- | ------------------------------------------------------------------------------------ | -| `get_trs_info` | Reports instance and version; unless `local_only`, also TRS info and tool classes. | -| `list_tools` | Lists one page of TRS tools, optionally filtered by name, class, language, etc. | -| `get_tool` | Retrieves one TRS tool by id, with all of its versions in full or summarized. | -| `get_tool_version` | Retrieves one version of a TRS tool: authors, images, languages, optionally files. | -| `get_tool_descriptor_by_path` | Fetches a version's primary descriptor, or any file get_tool_version lists, by path. | -| `search_entries` | Searches entries by keyword and facet, the equivalent of the site's Search page. | -| `get_entry` | Retrieves the requested fields of one entry. | -| `get_version` | Retrieves the requested fields of one version of an entry. | -| `get_file` | Retrieves the requested fields of one file belonging to a version. | +| Tool | Implemented | Description | +| ----------------------------- | ----------- | ------------------------------------------------------------------------------------ | +| `get_trs_info` | ✅ | Reports instance and version; unless `local_only`, also TRS info and tool classes. | +| `list_tools` | ✅ | Lists one page of TRS tools, optionally filtered by name, class, language, etc. | +| `get_tool` | ✅ | Retrieves one TRS tool by id, with all of its versions in full or summarized. | +| `get_tool_version` | ✅ | Retrieves one version of a TRS tool: authors, images, languages, optionally files. | +| `get_tool_descriptor_by_path` | ✅ | Fetches a version's primary descriptor, or any file get_tool_version lists, by path. | +| `search_entries` | ❌ | Searches entries by keyword and facet, the equivalent of the site's Search page. | +| `get_entry` | ❌ | Retrieves the requested fields of one entry. | +| `get_version` | ❌ | Retrieves the requested fields of one version of an entry. | +| `get_file` | ❌ | Retrieves the requested fields of one file belonging to a version. | The TRS tools, from `get_trs_info` to `get_tool_descriptor_by_path`, call Dockstore's GA4GH TRS V2 API directly. They form a chain: `list_tools` yields tool ids, a From 12f826c697074efcad9450b573b938c3945cf9fd Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 11:05:56 -0400 Subject: [PATCH 18/21] Merge ToolDetail into Tool The two differed only in whether versions could be summaries. Tool's versions now take either, with left_to_right so that a TRS response always parses as full versions, and get_tool swaps in summaries in place. Responses are byte-identical. Co-Authored-By: Claude Opus 5.5 --- src/dockstore_mcp/models.py | 23 ++++++++--------------- src/dockstore_mcp/tools/trs.py | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 3ae9f27..085dfee 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -44,7 +44,6 @@ "SortOrder", "Tool", "ToolClass", - "ToolDetail", "ToolFile", "ToolPage", "ToolSummary", @@ -303,8 +302,8 @@ class ToolVersionSummary(BaseModel): is_production: bool | None = Field(default=None, description="Whether the version is marked production-ready.") -class _ToolBase(BaseModel): - """The fields of a GA4GH TRS tool other than its versions, shared by :class:`Tool` and :class:`ToolDetail`.""" +class Tool(BaseModel): + """A GA4GH TRS tool: a Dockstore tool, workflow, or other entry as the TRS API describes it.""" id: str | None = Field(default=None, description="TRS identifier of the tool; pass this as tool_id.") url: str | None = Field(default=None, description="TRS API URL of the tool.") @@ -316,19 +315,13 @@ class _ToolBase(BaseModel): meta_version: str | None = Field(default=None, description="Revision of this tool's metadata.") has_checker: bool | None = Field(default=None, description="Whether the tool has a checker workflow.") checker_url: str | None = Field(default=None, description="TRS URL of the checker workflow, if any.") - - -class Tool(_ToolBase): - """A GA4GH TRS tool: a Dockstore tool, workflow, or other entry as the TRS API describes it.""" - - versions: list[ToolVersion] | None = Field(default=None, description="Every version of the tool.") - - -class ToolDetail(_ToolBase): - """A GA4GH TRS tool as get_tool returns it, with every version in full or summarized.""" - + # Parsed from the TRS API, versions are always in full: every ToolVersion field is + # optional, so left_to_right always picks it. Only get_tool(summary=True) swaps in + # summaries. versions: list[ToolVersion] | list[ToolVersionSummary] | None = Field( - default=None, description="Every version of the tool, in full or (if summarized) just its name and status." + default=None, + union_mode="left_to_right", + description="Every version of the tool, in full or (if summarized) just its name and status.", ) diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index 81e6b48..d45f5b3 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -37,7 +37,6 @@ FileWrapper, Tool, ToolClass, - ToolDetail, ToolFile, ToolPage, ToolSummary, @@ -99,12 +98,19 @@ def _segment(value: str) -> str: def _summarize(tool: Tool) -> ToolSummary: """Reduce ``tool`` to a :class:`ToolSummary`, shortening its description and version list.""" - versions = tool.versions or [] + versions: list[ToolVersion | ToolVersionSummary] = list(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 []}) + descriptor_types = sorted( + { + language + for version in versions + if isinstance(version, ToolVersion) + for language in version.descriptor_type or [] + } + ) description = " ".join((tool.description or "").split()) or None if description and len(description) > SUMMARY_DESCRIPTION_LENGTH: description = description[: SUMMARY_DESCRIPTION_LENGTH - 1].rstrip() + "…" @@ -283,7 +289,7 @@ async def get_tool( ) ), ] = False, - ) -> ToolDetail: + ) -> Tool: """Retrieve one tool or workflow by its TRS id, including every one of its versions. Each version's ``name`` is what the version tools take as ``version_id``, and @@ -297,10 +303,11 @@ async def get_tool( The tool's metadata and every one of its versions, in full or (if ``summary``) summarized. """ tool = Tool.model_validate(await get_json(f"/tools/{_segment(tool_id)}")) - versions: list[ToolVersion] | list[ToolVersionSummary] | None = tool.versions if summary and tool.versions is not None: - versions = [ToolVersionSummary.model_validate(version, from_attributes=True) for version in tool.versions] - return ToolDetail.model_validate({**dict(tool), "versions": versions}) + tool.versions = [ + ToolVersionSummary.model_validate(version, from_attributes=True) for version in tool.versions + ] + return tool @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) async def get_tool_version( From e6ec87dd116e800f86d653acf785758fcf28808a Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 11:05:56 -0400 Subject: [PATCH 19/21] Call the project a prototype without search, not a scaffold Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9eace6c..76846f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ An MCP (Model Context Protocol) server, built on FastMCP 4, that exposes Docksto assistants. It is a standalone process deployed alongside the Dockstore webservice, talking to Dockstore's GA4GH Tool Registry Service (TRS) API and its own proprietary API. -**Status: scaffold.** The GA4GH TRS tools in `trs.py` have working bodies. The four Dockstore +**Status: prototype without search.** The GA4GH TRS tools in `trs.py` have working bodies. The four Dockstore tools (`search_entries`, `get_entry`, `get_version`, `get_file`) are fully declared (names, arguments, response models, docstrings) but each raises `NotImplementedError` until wired up to the real Dockstore API. diff --git a/README.md b/README.md index b2bc980..09fc661 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ MCP, so it is deployed alongside the Dockstore webservice rather than inside it. It is built on [FastMCP](https://gofastmcp.com) 4 and ships as a container image. -> **Status: scaffold.** The GA4GH TRS tools (`get_trs_info` through +> **Status: prototype without search.** The GA4GH TRS tools (`get_trs_info` through > `get_tool_descriptor_by_path`) have working bodies; the other four Dockstore tools are > declared — names, arguments, and response shapes — but each one raises > `NotImplementedError` until it is wired up to the Dockstore API. From 5f5c542cff6597b2293e74abca0313e4fa920ade Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 11:28:27 -0400 Subject: [PATCH 20/21] Report the git ref as the server's version when it is set /health, get_trs_info's server_version, and the version the server reports to MCP clients all used the package version, 0.1.0, so none of them showed which release was running. They now share the User-Agent's Settings.server_version: the git ref the server was built from (the release tag, for published images), else the package version. Co-Authored-By: Claude Opus 5.5 --- src/dockstore_mcp/config.py | 7 ++++++- src/dockstore_mcp/models.py | 4 +++- src/dockstore_mcp/server.py | 5 ++--- src/dockstore_mcp/tools/trs.py | 3 +-- tests/test_config.py | 12 ++++++++---- tests/test_server.py | 13 +++++++++++++ 6 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/dockstore_mcp/config.py b/src/dockstore_mcp/config.py index 23d1f8a..48e2788 100644 --- a/src/dockstore_mcp/config.py +++ b/src/dockstore_mcp/config.py @@ -84,10 +84,15 @@ def api_url(self) -> str: """Base URL of the instance's proprietary Dockstore API.""" return f"{self.dockstore_url}/api" + @property + def server_version(self) -> str: + """Version this server reports: the git ref it was built from, else the package version.""" + return self.git_ref or __version__ + @property def user_agent(self) -> str: """User-Agent sent with every request to Dockstore, e.g. 'dockstore-mcp/1.21.0'.""" - return f"dockstore-mcp/{self.git_ref or __version__}" + return f"dockstore-mcp/{self.server_version}" @lru_cache(maxsize=1) diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 085dfee..f547262 100644 --- a/src/dockstore_mcp/models.py +++ b/src/dockstore_mcp/models.py @@ -209,7 +209,9 @@ class TrsInfo(BaseModel): """ dockstore_url: str = Field(description="The Dockstore instance this server is configured to talk to.") - server_version: str = Field(description="Version of the dockstore-mcp package that answered.") + server_version: str = Field( + description="Git tag or ref the answering server was built from, else its package version." + ) id: str | None = Field( default=None, description="Unique identifier of this service, in reverse domain name notation." ) diff --git a/src/dockstore_mcp/server.py b/src/dockstore_mcp/server.py index 7d4c320..8ec446b 100644 --- a/src/dockstore_mcp/server.py +++ b/src/dockstore_mcp/server.py @@ -19,7 +19,6 @@ from starlette.requests import Request from starlette.responses import JSONResponse -from dockstore_mcp import __version__ from dockstore_mcp.config import Settings, get_settings from dockstore_mcp.tools import register_all @@ -46,7 +45,7 @@ def create_server(settings: Settings | None = None) -> FastMCP: mcp: FastMCP = FastMCP( name="dockstore", - version=__version__, + version=settings.server_version, instructions=INSTRUCTIONS, website_url=settings.dockstore_url, ) @@ -54,7 +53,7 @@ def create_server(settings: Settings | None = None) -> FastMCP: @mcp.custom_route("/health", methods=["GET"], include_in_schema=False) async def health(_request: Request) -> JSONResponse: """Liveness probe for the container and any load balancer in front of it.""" - return JSONResponse({"status": "ok", "version": __version__}) + return JSONResponse({"status": "ok", "version": settings.server_version}) register_all(mcp, settings) logger.debug("Server built against Dockstore instance %s", settings.dockstore_url) diff --git a/src/dockstore_mcp/tools/trs.py b/src/dockstore_mcp/tools/trs.py index d45f5b3..1428af0 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -30,7 +30,6 @@ from fastmcp import FastMCP from pydantic import Field -from dockstore_mcp import __version__ from dockstore_mcp.casing import normalize_keys from dockstore_mcp.config import Settings from dockstore_mcp.models import ( @@ -214,7 +213,7 @@ async def get_trs_info( service's identifiers, the TRS API version implemented, the organization operating it, and every tool class it recognizes. """ - local = {"dockstore_url": settings.dockstore_url, "server_version": __version__} + local = {"dockstore_url": settings.dockstore_url, "server_version": settings.server_version} if local_only: return TrsInfo.model_validate(local) service_info, tool_classes = await asyncio.gather(get("/service-info"), get("/toolClasses")) diff --git a/tests/test_config.py b/tests/test_config.py index 0550be0..cb8a27f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -42,15 +42,19 @@ def test_derived_api_urls() -> None: assert settings.api_url == "https://qa.dockstore.org/api" -def test_user_agent_reports_the_git_ref(monkeypatch: pytest.MonkeyPatch) -> None: +def test_version_reports_the_git_ref(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DOCKSTORE_MCP_GIT_REF", "1.21.0") - assert Settings().user_agent == "dockstore-mcp/1.21.0" + settings = Settings() + assert settings.server_version == "1.21.0" + assert settings.user_agent == "dockstore-mcp/1.21.0" -def test_user_agent_falls_back_to_the_package_version(monkeypatch: pytest.MonkeyPatch) -> None: +def test_version_falls_back_to_the_package_version(monkeypatch: pytest.MonkeyPatch) -> None: # The Dockerfile sets DOCKSTORE_MCP_GIT_REF to "" when built without GIT_REF. monkeypatch.setenv("DOCKSTORE_MCP_GIT_REF", "") - assert Settings().user_agent == f"dockstore-mcp/{__version__}" + settings = Settings() + assert settings.server_version == __version__ + assert settings.user_agent == f"dockstore-mcp/{__version__}" @pytest.mark.parametrize(("field", "value"), [("port", 0), ("path", "mcp"), ("transport", "carrier-pigeon")]) diff --git a/tests/test_server.py b/tests/test_server.py index ce1b843..7af3d27 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,6 +19,8 @@ from starlette.testclient import TestClient from dockstore_mcp import __version__ +from dockstore_mcp.config import Settings +from dockstore_mcp.server import create_server async def test_every_tool_is_advertised(client: Client[Any]) -> None: @@ -42,3 +44,14 @@ def test_health_endpoint(server: FastMCP) -> None: response = http.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok", "version": __version__} + + +async def test_every_version_reports_the_git_ref(settings: Settings) -> None: + server = create_server(settings.model_copy(update={"git_ref": "0.1-alpha.4"})) + with TestClient(server.http_app()) as http: + assert http.get("/health").json()["version"] == "0.1-alpha.4" + async with Client(server) as client: + assert client.server_info is not None + assert client.server_info.version == "0.1-alpha.4" + result = await client.call_tool("get_trs_info", {"local_only": True}) + assert result.data.server_version == "0.1-alpha.4" From f0da6f0395616fc6c483b4c5e825be6eff780490 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Fri, 25 Sep 2026 11:34:38 -0400 Subject: [PATCH 21/21] Run CI and the release build on Ubuntu 26.04 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +++--- .github/workflows/deploy_tagged.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27d6eb9..1866ed7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: test: name: Test (Python ${{ matrix.python-version }}) - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 strategy: fail-fast: false matrix: @@ -34,7 +34,7 @@ jobs: git-secrets: name: git-secrets scan - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 - name: Install git-secrets @@ -51,7 +51,7 @@ jobs: docker: name: Build image - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 - name: Build diff --git a/.github/workflows/deploy_tagged.yml b/.github/workflows/deploy_tagged.yml index 556f0fb..d071fb5 100644 --- a/.github/workflows/deploy_tagged.yml +++ b/.github/workflows/deploy_tagged.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 permissions: id-token: write