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/ci.yml b/.github/workflows/ci.yml index acecca4..1a716a2 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 ca58b70..26f1ca7 100644 --- a/.github/workflows/deploy_tagged.yml +++ b/.github/workflows/deploy_tagged.yml @@ -19,7 +19,7 @@ jobs: build: needs: ci - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 permissions: id-token: write @@ -55,6 +55,7 @@ jobs: context: . load: true tags: quay.io/dockstore/dockstore-mcp:${{ steps.ref.outputs.sanitized }} + build-args: GIT_REF=${{ steps.ref.outputs.sanitized }} # Test the exact image about to be published, not just the one CI built - name: Smoke test diff --git a/CLAUDE.md b/CLAUDE.md index 4a2e76d..d99fb00 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: 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. @@ -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 @@ -94,7 +94,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/Dockerfile b/Dockerfile index 593358e..72cd67b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,10 @@ RUN pip install . \ # ---- runtime -------------------------------------------------------------- FROM python:3.13-alpine3.24 +# 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" \ @@ -56,7 +60,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 2954218..09fc661 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: 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. ## Requirements @@ -116,29 +116,39 @@ 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 -| 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 | 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 +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` 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 +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 @@ -152,9 +162,8 @@ 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, list_tool_classes + └── 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/config.py b/src/dockstore_mcp/config.py index 4978f4e..48e2788 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,16 @@ 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.server_version}" + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/src/dockstore_mcp/models.py b/src/dockstore_mcp/models.py index 087b7e4..f547262 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,21 @@ "EntryType", "File", "FileField", + "FileWrapper", + "ImageData", "ServiceOrganization", "ServiceType", "SortBy", "SortOrder", + "Tool", "ToolClass", + "ToolFile", + "ToolPage", + "ToolSummary", + "ToolVersion", + "ToolVersionSummary", + "ToolVersionWithFiles", + "TrsDescriptorType", "TrsInfo", "Version", "VersionField", @@ -191,13 +202,25 @@ class ServiceOrganization(BaseModel): class TrsInfo(BaseModel): - """GA4GH TRS service-info: metadata describing a Dockstore instance's TRS API.""" + """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="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." + ) + 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.") @@ -206,6 +229,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; list_tools filters by these.", + ) class ToolClass(BaseModel): @@ -216,6 +243,163 @@ 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" + JUPYTER = "JUPYTER" + SERVICE = "SERVICE" + + +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 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.""" + + 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.") + # 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, + union_mode="left_to_right", + 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.""" + + 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=( + "Names of up to 10 versions, production-ready ones first; pass one as version_id to the version tools. " + "Use get_tool 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.") + + +class ToolPage(BaseModel): + """One page of TRS tools, with enough context to fetch the rest.""" + + 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( + 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.""" + + 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 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." + ) + 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): """Fields of an :class:`Entry` that get_entry can return.""" 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/__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 3a09a22..1428af0 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -11,63 +11,368 @@ # 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`` yields tool ids, +``get_tool`` yields version names, and ``get_tool_version``'s +file listing yields the paths ``get_tool_descriptor_by_path`` takes. """ +import asyncio +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, + ToolPage, + ToolSummary, + ToolVersion, + ToolVersionSummary, + ToolVersionWithFiles, + TrsDescriptorType, + TrsInfo, +) __all__ = ["register"] #: How long to wait for the Dockstore TRS API to respond. REQUEST_TIMEOUT = 30.0 +#: 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 + +#: 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 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[ + bool, + Field( + description=( + "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." + ) + ), +] +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 _summarize(tool: Tool) -> ToolSummary: + """Reduce ``tool`` to a :class:`ToolSummary`, shortening its description and version list.""" + 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 + 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() + "…" + 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_names[:SUMMARY_VERSION_LIMIT], + version_count=len(version_names), + versions_truncated=len(version_names) > SUMMARY_VERSION_LIMIT, + 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") + 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 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) + 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. + + 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``. + """ + return (await get(path, params)).json() + + 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 + 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=[_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)}" @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. 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 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. Returns: - Service metadata: identifiers, the TRS API version implemented, and the - organization operating the service. + 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. """ - response = await client.get(f"{settings.trs_url}/service-info") - response.raise_for_status() - return TrsInfo.model_validate(normalize_keys(response.json())) + 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")) + 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 @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. + 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, + 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 get_trs_info.") + ] = 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, + summary: Summary = False, + ) -> ToolPage: + """List one page of the tools and workflows this Dockstore instance's TRS API serves, optionally filtered. - 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. + 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: - Every tool class the service recognizes. + Up to ``limit`` matching tools, each with all of its versions (or summarized, if ``summary``), plus + the total and next page's offset. """ - 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())] + 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} + return await get_tool_page(params, limit, offset, summary) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool( + 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, + ) -> 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 + 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: + 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)}")) + if summary and tool.versions is not None: + 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( + 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 and, if ``files`` is given, each file's path and type + (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)) + + 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( + tool_id: ToolId, + version_id: VersionId, + descriptor_type: DescriptorType, + relative_path: Annotated[ + str | None, + Field( + description=( + "Path of the file relative to the primary descriptor, as get_tool_version's files give. " + "Omit it to fetch the primary descriptor itself." + ) + ), + ] = None, + ) -> FileWrapper: + """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_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. + + Returns: + The file's content, checksum, and source URL. + """ + 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)) diff --git a/tests/test_config.py b/tests/test_config.py index 43de520..cb8a27f 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,21 @@ def test_derived_api_urls() -> None: assert settings.api_url == "https://qa.dockstore.org/api" +def test_version_reports_the_git_ref(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DOCKSTORE_MCP_GIT_REF", "1.21.0") + settings = Settings() + assert settings.server_version == "1.21.0" + assert settings.user_agent == "dockstore-mcp/1.21.0" + + +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", "") + 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")]) def test_rejects_bad_values(field: str, value: object) -> None: with pytest.raises(ValidationError): diff --git a/tests/test_server.py b/tests/test_server.py index 3500946..7af3d27 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 @@ -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: @@ -27,30 +29,29 @@ 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_descriptor_by_path", + "get_tool_version", "get_trs_info", "get_version", - "hello", - "list_tool_classes", + "list_tools", "search_entries", ] -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") 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" diff --git a/tests/test_trs.py b/tests/test_trs.py index 50ff895..8b66bf6 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. """ @@ -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", @@ -40,12 +42,104 @@ {"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, + }, +] + +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"} + +_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, + f"/tools/{TOOL_ID}": TOOL_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/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"}], } +#: 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 @@ -53,14 +147,20 @@ 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 +@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,9 +171,12 @@ 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] + 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]) @@ -91,18 +194,290 @@ 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" + assert result.data.dockstore_url == "https://staging.dockstore.org" + assert result.data.server_version == __version__ -async def test_list_tool_classes(client: Client[Any]) -> None: +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("list_tool_classes", {}) - assert [tool_class.id for tool_class in result.data] == ["CommandLineTool", "Workflow"] - assert result.data[1].name == "Workflow" + 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 == [] -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): 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": 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_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["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_list_tools_filters_and_summarizes(client: Client[Any]) -> None: + async with client: + 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]] + 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", {}) + assert dict(requests_made[0].url.params) == {"limit": "20", "offset": "0"} + + +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( + "list_tools", + {"toolname": "name", "tool_class": "Workflow", "descriptor_type": "NFL", "checker": False, "limit": 5}, + ) + 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: + 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_get_tool_returns_full_versions_by_default(client: Client[Any]) -> None: + async with client: + 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_get_tool_summarizes_versions(client: Client[Any]) -> None: + async with client: + result = await client.call_tool("get_tool", {"tool_id": TOOL_ID, "summary": True}) + assert result.structured_content is not None + assert result.structured_content["organization"] == "org" + [version] = result.structured_content["versions"] + 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}) + 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") + assert result.data.files is None + assert len(requests_made) == 1 + + +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_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( + 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_version_with_files(client: Client[Any]) -> None: + 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 [(file.path, file.file_type) for file in result.data.files] == [ + ("main.cwl", "PRIMARY_DESCRIPTOR"), + (SECONDARY_PATH, "SECONDARY_DESCRIPTOR"), + ] + assert result.data.files[1].checksum.checksum == "789abc" + + +@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_descriptor_by_path", + {"tool_id": TOOL_ID, "version_id": VERSION_ID, "descriptor_type": "CWL", "relative_path": relative_path}, + ) + 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( + "get_tool_version", {"tool_id": TOOL_ID, "version_id": VERSION_ID, "files": "JUPYTER"} + ) + assert [file.path for file in result.data.files] == ["main.ipynb"] + + +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_by_path", + {"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"}) + + +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__}"