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 2954218..33c69f2 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 @@ -116,29 +116,47 @@ 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 | 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, 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., or a notebook) of a version. | +| `get_tool_descriptor_by_path` | Fetches one of a version's files by its relative path. | +| `get_tool_files` | Lists every file of a version, without content. | +| `get_tool_tests` | Fetches a version's test parameter files. | +| `get_tool_containerfile` | Fetches the containerfile (e.g. Dockerfile) that builds a version's image. | +| `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. 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 +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 +172,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/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/models.py b/src/dockstore_mcp/models.py index 087b7e4..14f6950 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,19 @@ "EntryType", "File", "FileField", + "FileWrapper", + "ImageData", "ServiceOrganization", "ServiceType", "SortBy", "SortOrder", + "Tool", "ToolClass", + "ToolFile", + "ToolPage", + "ToolSummary", + "ToolVersion", + "TrsDescriptorType", "TrsInfo", "Version", "VersionField", @@ -216,6 +225,129 @@ 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 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 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] | 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 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..3219d84 100644 --- a/src/dockstore_mcp/tools/trs.py +++ b/src/dockstore_mcp/tools/trs.py @@ -11,34 +11,160 @@ # 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, + ToolPage, + ToolSummary, + 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 + +#: 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."), +] +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."), +] + + +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.""" + 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") + 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: @@ -71,3 +197,174 @@ 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, summary: Summary = False) -> ToolPage: + """List one page of every tool and workflow this Dockstore instance's TRS API serves. + + Reach for search_tools instead to narrow the list by name, language, class, + or other filters; this one pages through everything. The page's ``total`` + says how many tools there are in all, so to count them, ask for one page + with ``limit`` 1. Fetch the next page by passing ``next_offset`` as + ``offset`` (a page number, not an item index); it is unset on the last page. + + Returns: + Up to ``limit`` tools, each with all of its versions (or summarized, if ``summary``), plus the + total and next page's offset. + """ + return await get_tool_page({}, limit, offset, summary) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def search_tools( + 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, + summary: Summary = False, + ) -> ToolPage: + """Find tools and workflows through the TRS API by name, language, class, and other filters. + + Every filter given must match; text filters match substrings. Page through + the results, or count them, as with list_tools. For richer keyword search + with facets, the Dockstore Search page equivalent is search_entries. + + Returns: + 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, + "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) -> 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, or notebook. + + Reach for get_tool_files to see what other files the version has, and + get_tool_descriptor_by_path to fetch one of them. + + Returns: + The descriptor's content, checksum, and source URL. + """ + path = f"{version_path(tool_id, version_id)}/{descriptor_type}/descriptor" + return FileWrapper.model_validate(await get_json(path)) + + @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}) + async def get_tool_descriptor_by_path( + tool_id: ToolId, + version_id: VersionId, + descriptor_type: DescriptorType, + relative_path: Annotated[ + str, + Field(description="Path of the file relative to the primary descriptor, as get_tool_files gives."), + ], + ) -> 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_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_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..376d146 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,105 @@ {"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, + 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, + 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 +148,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 +172,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]) @@ -106,3 +210,208 @@ 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": 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["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", {}) + 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, "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_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_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): + 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"}) + + +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__}"