From 2e2fc588915a28bcc396b82ac2a68882e1a4cf60 Mon Sep 17 00:00:00 2001 From: Denis Yuen Date: Wed, 23 Sep 2026 16:49:00 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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", {})