Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ jobs:
env:
CONFORMANCE_MCP_URL: http://127.0.0.1:${{ env.MCP_PORT }}
run: uv run pytest tests/conformance/test_transport_v1.py -v
- name: Run behaviour probe
# Behaviour Conformance v1 — the three recurring contract bugs (silent-empty filter,
# lying total/truncated, error an LLM cannot act on). Every probe is derived from this
# server's own advertised schema; there is no per-repo probe list to maintain.
env:
CONFORMANCE_MCP_URL: http://127.0.0.1:${{ env.MCP_PORT }}
run: uv run pytest tests/conformance/test_behaviour_v1.py -v
- name: Logs on failure
if: failure()
run: make docker-logs || docker compose -f docker/docker-compose.yml logs
Expand Down
8 changes: 4 additions & 4 deletions .loc-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#
# Decomposition backlog: docs/superpowers/specs/2026-05-25-modernize-python-stack-and-agent-workflow-design.md

stringdb_link/api/client.py:796
stringdb_link/services/stringdb_service.py:776
stringdb_link/models/requests.py:682
stringdb_link/models/responses.py:625
stringdb_link/api/client.py:805
stringdb_link/services/stringdb_service.py:911
stringdb_link/models/requests.py:713
stringdb_link/models/responses.py:681
81 changes: 81 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,87 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.1.0] - 2026-07-15

GeneFoundry fleet MCP contract-hardening sweep. Vendors the behaviour gate
(`docs/conformance/behaviour.py`, byte-identical from `genefoundry-router@791363c`)
and closes every contract defect it — and the live MCP audit (#33) — surfaced.
Behaviour gate: 20 fail / 10 UNGATED → **0 fail / 0 UNGATED (CONFORMANT)**.
Surface: 4,625t → **4,083t**, `outputSchema` 24% → **0%**, `doc%` 100%, 0 → 26 examples.

### Fixed

- **`isError` is now set on every error envelope — for BOTH the raised and the
returned-dict paths.** The MCP error carrier returned `ToolResult(structured_content=...)`
without `is_error=True`, so a client branching on the protocol `isError` bit saw every
failed call as a success. Verified against fastmcp 3.4.4 that the return path keeps
`structuredContent` while carrying `is_error=True`; errors now route through
`ToolResult(..., is_error=True)`. A payload a tool *returns* as `{"success": false}`
(rather than raising) is likewise normalized to an error frame with `isError:true`,
never re-wrapped as a success.
- **`error_code` is closed to the six-value canonical enum** (`invalid_input`,
`not_found`, `ambiguous_query`, `upstream_unavailable`, `rate_limited`, `internal`):
`internal_error` → `internal`.
- **Validation errors now name the offending parameter.** The error frame carries
`field`/`field_errors`, derived ONLY from the request-validation `loc` path (this
server's own schema parameter names, never caller/upstream prose), so an LLM can
self-correct.
- **`get_network_link`: `output_format` no longer silently returns an empty result.**
8 of its 9 declared enum values returned `{"success": true, "result": {}}`. The four
formats STRING actually serves for a link (`json`, `tsv`, `tsv-no-header`, `xml`) are
kept and each is shaped into a structured result — `json` fills `url`; the text
formats fill `formatted` (STRING's raw serialization) with the URL also extracted
into `url`. STRING's `psi-mi`/`image`/`svg` link formats (which carry no link
payload) are dropped from the enum. (Silent-empty filter, without capability loss.)
- **`compute_functional_enrichment` / `compute_ppi_enrichment`: ANY STRING in-band
error is now `invalid_input` naming a field, not a false `upstream_unavailable`.**
STRING returns HTTP 200 with `[{"error": "...", ...}]` for a bad request; this was
mis-mapped to a retryable "upstream unavailable". The general class is now handled —
`background_error` names `background_string_identifiers`, and any other in-band error
names `identifiers` — as a non-retryable `invalid_input` (upstream prose never
echoed), so a new STRING error type can never regress to a field-less error or a
false retryable outage.
- **`get_functional_annotations` works again.** Sending `allow_pubmed`/`only_pubmed`
(even `=0`) made STRING attach PMID annotations and balloon the response past the
byte cap, so the tool always failed. The flags are now sent only when opted in.
- **`get_network_image` returns an image again — on the MCP surface only.** STRING's
binary body cannot ride inside a structured MCP envelope, so the tool returned an
internal error or an empty result. The MCP tool now returns a base64-encoded
`NetworkImageResult` (format, content-type, size, `image_base64`) via a dedicated
`POST /api/images/network/json` route; the REST `POST /api/images/network` endpoint
keeps its raw-binary contract (renamed operation `download_network_image`, excluded
from MCP). An empty upstream image body is now a `upstream_unavailable` error, never
a `success` with zero bytes.
- **`get_interaction_partners` reports an honest, limit-invariant `total_count` and
paginates PER PROTEIN.** It reported `total_count = len(returned page)` and
head-sliced the flattened partner list — which for a multi-protein query returned
only the first protein's partners and omitted every later protein. It now fetches the
full partner set (so the total is the true count, invariant of `limit`), applies
`limit` per query protein so every queried protein is represented, and sets
`truncated`.

### Added

- **`compute_functional_enrichment` gains `limit` and `category`.** An unfiltered
30-gene panel returned ~992 terms (~421k tokens). `limit` (1–1000, default 100)
returns the most-significant terms first; `category` (a closed `EnrichmentCategory`
enum) filters to one STRING category. `total_count` still reports the full number of
matching terms and `truncated` signals more exist, so a smaller `limit` never hides
how many there are.
- **Tool-Schema Documentation Standard v1**: every required and array parameter now
carries `examples`; closed vocabularies are declared as enums matching the runtime.
- Vendored behaviour gate (`tests/conformance/behaviour.py`,
`tests/conformance/test_behaviour_v1.py`) wired into `mcp-conformance` CI alongside
the transport probe.

### Changed

- **Tool-Surface Budget Standard v1**: `outputSchema` is suppressed on every tool
(optional in MCP, unread by models, 24% of the surface) and
`FastMCP(dereference_schemas=False)`. `structuredContent` is unaffected (every tool
returns a dict envelope) and untrusted-text fencing still happens on the wire
(Response-Envelope Standard v1.1a).

## [4.0.6] - 2026-07-14

### Changed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "stringdb-link"
version = "4.0.6"
version = "4.1.0"
description = "High-performance unified API server for STRING protein-protein interaction database with MCP integration"
readme = "README.md"
license = { text = "MIT" }
Expand Down
23 changes: 16 additions & 7 deletions stringdb_link/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ async def get_interaction_partners(
self,
identifiers: list[str],
species: int | None = None,
limit: int = 10,
limit: int | None = 10,
required_score: int = 400,
network_type: str = "functional",
output_format: OutputFormat = OutputFormat.JSON,
Expand All @@ -429,21 +429,24 @@ async def get_interaction_partners(
Args:
identifiers: List of protein identifiers
species: NCBI taxon ID
limit: Maximum number of partners per protein
limit: Maximum number of partners PER PROTEIN. ``None`` omits the
parameter so STRING returns the full partner set (used to compute a
true, limit-invariant total before applying a per-protein cap).
required_score: Minimum confidence score on STRING's 0-1000 integer scale (e.g. 400 = 0.4)
network_type: Network type (functional or physical)
output_format: Output format (json, tsv, xml, psi-mi)

Returns:
List of interaction partners (JSON) or formatted string (TSV/XML/PSI-MI)
"""
params = {
params: dict[str, Any] = {
"identifiers": "\r".join(identifiers),
"limit": limit,
"required_score": required_score,
"network_type": network_type,
"caller_identity": self.caller_identity,
}
if limit is not None:
params["limit"] = limit

if species:
params["species"] = species
Expand Down Expand Up @@ -506,12 +509,18 @@ async def get_functional_annotation(
Returns:
List of functional annotations (JSON) or formatted string (TSV/XML/PSI-MI)
"""
params = {
params: dict[str, Any] = {
"identifiers": "\r".join(identifiers),
"allow_pubmed": 1 if allow_pubmed else 0,
"only_pubmed": 1 if only_pubmed else 0,
"caller_identity": self.caller_identity,
}
# STRING treats the mere PRESENCE of these flags (even "=0") as a request to
# include PMID publication annotations, which balloons the response to tens
# of MB and trips the response byte cap (ResponseTooLargeError). Send them
# ONLY when a caller explicitly opts in.
if allow_pubmed:
params["allow_pubmed"] = 1
if only_pubmed:
params["only_pubmed"] = 1

if species:
params["species"] = species
Expand Down
26 changes: 18 additions & 8 deletions stringdb_link/api/routes/enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@ async def get_functional_enrichment(
field=e.field,
value=e.value,
)
raise HTTPException(
status_code=400,
detail=f"Validation error: {e.message}",
) from e
# Emit the FastAPI validation-list shape when the offending parameter is
# known, so the error envelope can name it (e.g. background_string_identifiers)
# without echoing any upstream prose. The fixed "msg" is never surfaced.
detail: object = (
[{"loc": ["body", e.field], "msg": "invalid value"}]
if e.field
else f"Validation error: {e.message}"
)
raise HTTPException(status_code=400, detail=detail) from e

except Exception as e:
logger.exception(
Expand Down Expand Up @@ -115,10 +120,15 @@ async def get_ppi_enrichment(
field=e.field,
value=e.value,
)
raise HTTPException(
status_code=400,
detail=f"Validation error: {e.message}",
) from e
# Emit the FastAPI validation-list shape when the offending parameter is
# known, so the error envelope can name it (e.g. background_string_identifiers)
# without echoing any upstream prose. The fixed "msg" is never surfaced.
detail: object = (
[{"loc": ["body", e.field], "msg": "invalid value"}]
if e.field
else f"Validation error: {e.message}"
)
raise HTTPException(status_code=400, detail=detail) from e

except Exception as e:
logger.exception(
Expand Down
104 changes: 66 additions & 38 deletions stringdb_link/api/routes/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

from __future__ import annotations

import base64
from typing import TYPE_CHECKING

from fastapi import APIRouter, HTTPException, Response

from stringdb_link.exceptions import StringDBServiceError, ValidationError
from stringdb_link.models.requests import ImageRequest
from stringdb_link.models.responses import NetworkImageResult
from stringdb_link.services.stringdb_service import StringDBService

from .dependencies import LoggerDep, StringDBServiceDep
Expand All @@ -17,56 +19,82 @@

router = APIRouter()

_EMPTY_IMAGE_DETAIL = "STRING returned an empty image for this network; no image is available."


def _translate_image_errors(e: Exception, logger: FilteringBoundLogger) -> HTTPException:
"""Map a service/validation/unexpected failure to the right HTTP status."""
if isinstance(e, StringDBServiceError):
logger.exception("Service error during network image generation", error=str(e))
return HTTPException(status_code=e.status_code or 500, detail=f"Service error: {e.message}")
if isinstance(e, ValidationError):
logger.exception("Validation error during network image generation", field=e.field)
return HTTPException(status_code=400, detail=f"Validation error: {e.message}")
logger.exception("Unexpected error during network image generation", error=str(e))
return HTTPException(
status_code=500, detail="Internal server error during network image generation"
)


@router.post(
"/images/network",
operation_id="download_network_image",
tags=["visualization"],
response_class=Response,
responses={200: {"content": {"image/png": {}, "image/svg+xml": {}}}},
)
async def download_network_image(
request: ImageRequest,
service: StringDBService = StringDBServiceDep,
logger: FilteringBoundLogger = LoggerDep,
) -> Response:
"""Download the raw binary network image (REST clients).

Returns STRING's image bytes with their native media type. This is the REST
download contract; the MCP surface uses ``get_network_image`` (JSON base64),
since binary cannot ride inside a structured MCP envelope. Excluded from MCP.
"""
try:
logger.info("Downloading network image", identifiers=request.identifiers)
image = (await service.get_network_image(request)).image
return Response(content=image.image_data or b"", media_type=image.content_type)
except Exception as e: # every failure is translated to an HTTP status
raise _translate_image_errors(e, logger) from e


@router.post(
"/images/network/json",
response_model=NetworkImageResult,
operation_id="get_network_image",
tags=["visualization"],
)
async def get_network_image(
request: ImageRequest,
service: StringDBService = StringDBServiceDep,
logger: FilteringBoundLogger = LoggerDep,
) -> Response:
"""Generate protein network visualization image."""
) -> NetworkImageResult:
"""Generate a protein network visualization image as base64 (MCP surface)."""
try:
logger.info("Generating network image", identifiers=request.identifiers)
image = (await service.get_network_image(request)).image
image_bytes = image.image_data or b""

image_response = await service.get_network_image(request)
image_data = image_response.image.image_data
content_type = image_response.image.content_type
# An empty upstream body is NOT a successful zero-byte image — that would be a
# silent-empty success. Surface it as a retryable upstream failure instead.
if not image_bytes:
raise HTTPException(status_code=502, detail=_EMPTY_IMAGE_DETAIL)

return Response(content=image_data, media_type=content_type)

except StringDBServiceError as e:
logger.exception(
"Service error during network image generation",
error=str(e),
operation=e.operation,
)
raise HTTPException(
status_code=e.status_code or 500,
detail=f"Service error: {e.message}",
) from e

except ValidationError as e:
logger.exception(
"Validation error during network image generation",
error=str(e),
field=e.field,
value=e.value,
# STRING returns binary image data, which cannot ride inside a structured MCP
# envelope. Encode it as base64 so the tool returns a non-empty, decodable
# result instead of an empty structured payload / internal error.
return NetworkImageResult(
image_format=image.image_format,
content_type=image.content_type,
image_size_bytes=len(image_bytes),
image_base64=base64.b64encode(image_bytes).decode("ascii"),
)
raise HTTPException(
status_code=400,
detail=f"Validation error: {e.message}",
) from e

except Exception as e:
logger.exception(
"Unexpected error during network image generation",
error=str(e),
)
raise HTTPException(
status_code=500,
detail="Internal server error during network image generation",
) from e

except HTTPException:
raise
except Exception as e: # every failure is translated to an HTTP status
raise _translate_image_errors(e, logger) from e
Loading