From 3afd4bd14f8bdaa0d5ad04f574a3ca6c8841e267 Mon Sep 17 00:00:00 2001 From: Bernt Popp Date: Tue, 14 Jul 2026 08:22:31 +0200 Subject: [PATCH 1/2] docs: refactor README to GeneFoundry README Standard v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README had become an unversioned operator manual (289 lines) that no reader scanned and no CI verified — and parts of it had rotted: it advertised env vars that the nested Pydantic settings no longer accept (STRINGDB_BASE_URL, CACHE_IDENTIFIER_TTL, LOG_LEVEL), a serverInfo.name ("StringDB-Link Server") that the ratified conformance gate contradicts, a required_score example (400) outside the model's 0.0-1.0 bounds, and clone URLs for an org that does not host this repo. Rewrite it to the standard's fixed shape (119 lines): four machine-maintained badges, lead paragraph, research-use callout above the fold, Why, Quick start, Tools, Data & provenance, Documentation, Contributing, License. Nothing is deleted — content is relocated, and the destinations are created in this commit: - docs/configuration.md — every env var with its CORRECT nested name (STRINGDB_API__*, CACHE__*, CORS__*, ...), the exact Host/Origin request guards (wildcards rejected; add the proxy hostname), the CORS credentialed-wildcard startup failure, the two-tier cache TTLs, log redaction, and the MCP identity contract. - docs/deployment.md — the two console scripts, the tri-modal transport table with the http-serves-no-/mcp footgun, health endpoints, Docker/Compose, reverse-proxy rules, Claude Desktop stdio config, and the outstanding operator action from SECURITY.md (secret scanning / push protection). - docs/architecture.md — module layout, how FastMCP.from_fastapi generates the tool surface from route operation_ids (and which routes are excluded), the MCP hardening layers, the federation contract, and the REST API examples. - docs/data.md — the STRING v12.0 pin and why it is load-bearing, the upstream endpoint map, TTL rationale, the 1 req/s courtesy rate that must not be bypassed, caller_identity, the CC BY 4.0 data licence, and the citation. - AGENTS.md — pre-commit hook install, make lint-readme, and documentation discipline so the README does not re-bloat. Stale facts are corrected against the code rather than carried over; marketing prose (emoji feature lists, unverifiable performance claims, a readthedocs site that does not exist) is dropped. Two gates keep this true: - scripts/check_readme.py (verbatim fleet copy) — length, section order, badge row, research-use callout, link integrity, hand-typed derived facts. Wired into the Makefile as lint-readme and added to ci-local. - tests/unit/test_readme_tools.py — asserts the README's ## Tools table equals the server's registered tools exactly, enumerating them through the same `facade` fixture test_tool_names.py uses. Verified to fail when a row drifts. make ci-local is green. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 20 ++ Makefile | 7 +- README.md | 378 +++++++++----------------------- docs/architecture.md | 119 ++++++++++ docs/configuration.md | 135 ++++++++++++ docs/data.md | 104 +++++++++ docs/deployment.md | 132 +++++++++++ scripts/check_readme.py | 188 ++++++++++++++++ tests/unit/test_readme_tools.py | 64 ++++++ 9 files changed, 871 insertions(+), 276 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/data.md create mode 100644 docs/deployment.md create mode 100644 scripts/check_readme.py create mode 100644 tests/unit/test_readme_tools.py diff --git a/AGENTS.md b/AGENTS.md index 72dd57d..c3827af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,9 @@ Required checks before claiming completion: - `make ci-local` +One-time local setup: `make install`, then `uv run pre-commit install` to install the +git hooks (Ruff, mypy, and the per-file line budget). + Useful focused commands: - `make install` @@ -48,6 +51,7 @@ Useful focused commands: - `make lint` - `make lint-fix` - `make lint-loc` +- `make lint-readme` - `make typecheck` - `make typecheck-fast` - `make test` @@ -72,6 +76,22 @@ Useful focused commands: - Keep FastAPI route behavior covered by route tests and service behavior covered by unit tests. +## Documentation Discipline + +`README.md` follows the **GeneFoundry README Standard v1** and is machine-checked by +`make lint-readme` (`scripts/check_readme.py`, copied verbatim across the fleet). It is +the front door, not the manual: fixed section order, a 200-line hard ceiling, no +hand-typed counts or scores. Reference docs live in `docs/`: + +- `docs/configuration.md` - environment variables and request guards +- `docs/deployment.md` - transports, entry points, Docker, reverse proxy +- `docs/architecture.md` - module layout, REST-to-MCP generation, REST examples +- `docs/data.md` - STRING sources, caching, licence, citation + +Add a tool, update the README's `## Tools` table: `tests/unit/test_readme_tools.py` +asserts the table equals the registered tool surface exactly, and CI fails otherwise. +Do not move operator or reference detail back into the README; extend `docs/` instead. + ## File Size Discipline Hard cap: **600 lines per Python module** in `stringdb_link/`, `server.py`, and diff --git a/Makefile b/Makefile index 619b745..6f71539 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install lock upgrade sync format format-check lint lint-ci lint-fix lint-loc typecheck typecheck-fast typecheck-stop typecheck-fresh test test-fast test-unit test-integration test-cov test-all check ci-local precommit clean dev mcp-serve mcp-serve-http docker-build docker-up docker-down docker-logs +.PHONY: help install lock upgrade sync format format-check lint lint-ci lint-fix lint-loc lint-readme typecheck typecheck-fast typecheck-stop typecheck-fresh test test-fast test-unit test-integration test-cov test-all check ci-local precommit clean dev mcp-serve mcp-serve-http docker-build docker-up docker-down docker-logs DOCKER_COMPOSE := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; else echo "docker compose"; fi) @@ -36,6 +36,9 @@ lint-fix: ## Lint and apply safe fixes lint-loc: ## Enforce per-file line budget (see AGENTS.md "File Size Discipline") uv run python scripts/check_file_size.py +lint-readme: ## Enforce the GeneFoundry README Standard v1 + uv run python scripts/check_readme.py + typecheck: ## Type check package uv run mypy stringdb_link server.py mcp_server.py @@ -89,7 +92,7 @@ test-all: test-cov ## Alias for full test run with coverage check: format lint ## Format and lint -ci-local: format-check lint-ci lint-loc typecheck-fast test-fast ## Run fast local CI-equivalent checks +ci-local: format-check lint-ci lint-loc lint-readme typecheck-fast test-fast ## Run fast local CI-equivalent checks precommit: ci-local ## Run checks expected before commit diff --git a/README.md b/README.md index fdab55a..99edf14 100644 --- a/README.md +++ b/README.md @@ -1,289 +1,119 @@ -# StringDB-Link +# stringdb-link -[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![CI](https://github.com/berntpopp/stringdb-link/actions/workflows/ci.yml/badge.svg)](https://github.com/berntpopp/stringdb-link/actions/workflows/ci.yml) +[![Conformance](https://github.com/berntpopp/stringdb-link/actions/workflows/conformance.yml/badge.svg)](https://github.com/berntpopp/stringdb-link/actions/workflows/conformance.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) -High-performance unified API server for the STRING protein-protein interaction database with both REST API and MCP (Model Context Protocol) support. +An **MCP server over [STRING](https://string-db.org/)**, the protein–protein association +network and functional-enrichment database. It serves STRING v12.0 as typed MCP tools over +Streamable HTTP, with the same surface available as a FastAPI REST API. -## 🚀 Features +> [!IMPORTANT] +> Research use only. Not clinical decision support. Do not use for diagnosis, +> treatment, triage, or patient management. -- **Dual Protocol Support**: Both HTTP REST API and MCP for AI applications -- **Comprehensive Coverage**: All major STRING API endpoints -- **High Performance**: Async/await throughout with intelligent caching -- **Type Safety**: Complete type hints with Pydantic validation -- **Rate Limiting**: Respects STRING's API rate limits -- **Error Resilience**: Retry logic with exponential backoff -- **Claude Desktop Ready**: Seamless integration with Claude Desktop +## Why -## 📋 Supported STRING Operations +STRING has a public HTTP API, but it is built for scripts pasted into a browser, not for +agents. Four concrete frictions: -### Core Functionality -- **Protein Identifier Resolution**: Map gene names, UniProt IDs to STRING identifiers -- **Network Interactions**: Retrieve protein-protein interaction networks -- **Interaction Partners**: Get all interaction partners for proteins -- **Functional Enrichment**: Gene Ontology and pathway enrichment analysis -- **Functional Annotations**: Protein annotations from multiple databases -- **Network Visualization**: Generate protein network images (PNG/SVG) +- **Nothing is keyed on gene symbols.** Every useful call wants a STRING protein ID + (`9606.ENSP00000269305`); getting one is a separate resolution step. +- **Multi-protein queries are string-encoded.** Identifiers are joined with a literal + carriage return (`%0d`) inside a query parameter — easy to get silently wrong. +- **Results are untyped rows** with terse column names (`stringId_A`, `escore`, `fscore`), + with no schema to validate against. +- **The default host is a moving target.** STRING tells integrators to pin a versioned host + (`version-12-0.string-db.org`) so the same query keeps returning the same answer across + releases — and asks callers to wait one second between calls and to identify themselves. -### Advanced Features -- **Homology Analysis**: Protein similarity scores and cross-species homology -- **PPI Enrichment**: Statistical analysis of interaction networks -- **Enrichment Visualization**: Generate enrichment analysis figures -- **Web Links**: Direct links to STRING website networks +This server absorbs all four: it pins STRING v12.0, resolves identifiers, validates every +parameter with Pydantic, returns typed envelopes, throttles to STRING's courtesy rate, and +caches by result class. No data bundle, no ingest, no build step — it proxies STRING live. -## 🛠️ Installation +## Quick start -### From Source -```bash -git clone https://github.com/stringdb-link/stringdb-link.git -cd stringdb-link -pip install -e ".[dev]" -``` - -### Using pip (when published) -```bash -pip install stringdb-link -``` - -## ⚡ Quick Start - -### Start HTTP Server -```bash -stringdb-link server --host 0.0.0.0 --port 8000 -``` - -### Start MCP Server (for Claude Desktop) -```bash -stringdb-link mcp -``` +Hosted — no install: -### Start Unified Server (HTTP + MCP) ```bash -stringdb-link server --transport unified --port 8000 +claude mcp add --transport http stringdb https://stringdb-link.genefoundry.org/mcp ``` -## 🔧 Configuration - -Create a `.env` file or set environment variables: +Local (Python 3.12+, [uv](https://github.com/astral-sh/uv)): ```bash -# Server Configuration -HOST=127.0.0.1 -PORT=8000 -TRANSPORT=unified -ALLOWED_HOSTS=["localhost","127.0.0.1","::1"] -ALLOWED_ORIGINS=[] - -# StringDB API Configuration -STRINGDB_BASE_URL=https://version-12-0.string-db.org/api -STRINGDB_RATE_LIMIT_DELAY=1.0 - -# Caching Configuration -CACHE_ENABLED=true -CACHE_IDENTIFIER_TTL=86400 # 24 hours -CACHE_NETWORK_TTL=43200 # 12 hours - -# Logging Configuration -LOG_LEVEL=INFO -LOG_FORMAT=json -``` - -HTTP deployments enforce exact Host and Origin allowlists. Add the public -reverse-proxy hostname to `ALLOWED_HOSTS`; wildcard host patterns are rejected. -`ALLOWED_ORIGINS=[]` permits requests without an `Origin` header. These request -guards are independent of the separate `CORS__ALLOW_ORIGINS` response policy. - -## 📚 API Usage Examples - -### Resolve Protein Identifiers -```bash -curl -X POST "http://localhost:8000/api/identifiers/resolve" \ - -H "Content-Type: application/json" \ - -d '{ - "identifiers": ["p53", "BRCA1", "cdk2"], - "species": 9606, - "echo_query": true - }' -``` - -### Get Protein Interactions -```bash -curl -X POST "http://localhost:8000/api/networks/interactions" \ - -H "Content-Type: application/json" \ - -d '{ - "identifiers": ["TP53", "MDM2", "ATM"], - "species": 9606, - "required_score": 400, - "network_type": "functional" - }' -``` - -### Functional Enrichment Analysis -```bash -curl -X POST "http://localhost:8000/api/enrichment/functional" \ - -H "Content-Type: application/json" \ - -d '{ - "identifiers": ["TP53", "MDM2", "ATM", "CHEK2", "BRCA1"], - "species": 9606 - }' -``` - -## 🤖 MCP Integration (Claude Desktop) - -Add to your Claude Desktop configuration: - -```json -{ - "mcpServers": { - "stringdb-link": { - "command": "stringdb-link", - "args": ["mcp"], - "env": { - "STRINGDB_RATE_LIMIT_DELAY": "1.0" - } - } - } -} -``` - -### Available MCP Tools - -Tool names follow the [GeneFoundry Tool-Naming Standard v1](#tool-naming-standard) -(`verb_noun` snake_case, canonical verb, unprefixed): - -- `resolve_protein_identifiers`: Map protein names/symbols to STRING IDs -- `search_protein_interactions`: Find protein network interactions -- `get_interaction_partners`: Get interaction partners for proteins -- `get_network_link`: Get a shareable STRING network view URL -- `compute_functional_enrichment`: Run STRING functional enrichment analysis -- `compute_ppi_enrichment`: Run STRING protein-protein interaction enrichment test -- `get_functional_annotations`: Retrieve STRING functional annotations -- `get_protein_homology_scores`: Get cross-species homology bit-scores -- `get_protein_homology_best_hits`: Get best cross-species homology hits -- `get_network_image`: Get a STRING network visualization image - -### Tool-Naming Standard - -This server is part of the **GeneFoundry MCP router** (`genefoundry-router`) fleet. - -- **`serverInfo.name`** is set explicitly to `StringDB-Link Server`. -- **Canonical gateway namespace token: `stringdb`.** Leaf tools are exposed - *unprefixed*; the router applies the namespace at mount time, so tools surface - at the gateway as `stringdb_` (e.g. `stringdb_search_protein_interactions`). - Do **not** add a `stringdb_` self-prefix to tool names — that would - double-prefix at the gateway. - -A CI guard (`tests/unit/test_tool_names.py`) asserts that every registered tool -matches `^[a-z0-9_]{1,50}$`, starts with a canonical verb -(`get`/`search`/`list`/`resolve`/`find`/`compare`/`compute`), and does not -self-prefix the `stringdb` namespace token. - -## 🧪 Development - -### Setup Development Environment -```bash -git clone https://github.com/stringdb-link/stringdb-link.git -cd stringdb-link -pip install -e ".[dev]" -pre-commit install -``` - -### Run Tests -```bash -pytest -``` - -### Run Linting -```bash -ruff check . -ruff format . -mypy . -``` - -### Check Configuration -```bash -stringdb-link validate-config -``` - -### Health Check -```bash -stringdb-link health -``` - -## 📖 API Documentation - -Once the server is running, visit: -- **Swagger UI**: `http://localhost:8000/docs` -- **ReDoc**: `http://localhost:8000/redoc` -- **OpenAPI JSON**: `http://localhost:8000/openapi.json` - -## 🏗️ Architecture - -StringDB-Link follows a clean architecture pattern: - -``` -├── api/ # HTTP client and route handlers -│ ├── client.py # StringDB HTTP client with caching -│ └── routes/ # FastAPI route definitions -├── models/ # Pydantic data models -│ ├── requests.py # Request validation models -│ ├── responses.py # Response serialization models -│ └── stringdb.py # StringDB-specific enums and constants -├── services/ # Business logic layer -├── utils/ # Shared utilities -├── config.py # Configuration management -├── exceptions.py # Custom exception classes -└── logging_config.py # Structured logging setup -``` - -## 📊 Performance - -- **Response Time**: < 2 seconds for most queries -- **Cache Hit Rate**: > 80% for repeated queries -- **Concurrent Requests**: Supports 100+ concurrent requests -- **Memory Usage**: Efficient with configurable cache limits - -## 🔒 Security - -- Input validation with Pydantic models -- Rate limiting to prevent abuse -- No sensitive data logging -- Optional API key authentication -- CORS configuration for web apps - -See [`SECURITY.md`](SECURITY.md) for the vulnerability-reporting process and the -required repository settings (secret scanning / push protection) an operator must enable. - -## 🤝 Contributing - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Make your changes -4. Add tests for new functionality -5. Run the test suite (`pytest`) -6. Run linting (`ruff check . && mypy .`) -7. Commit your changes (`git commit -m 'Add amazing feature'`) -8. Push to the branch (`git push origin feature/amazing-feature`) -9. Open a Pull Request - -## 📄 License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## 🙏 Acknowledgments - -- [STRING Database](https://string-db.org/) for providing the comprehensive protein interaction data -- [FastAPI](https://fastapi.tiangolo.com/) for the excellent web framework -- [FastMCP](https://github.com/jlowin/fastmcp) for MCP integration -- [Pydantic](https://pydantic-docs.helpmanual.io/) for data validation - -## 📞 Support - -- **Issues**: [GitHub Issues](https://github.com/stringdb-link/stringdb-link/issues) -- **Documentation**: [Full Documentation](https://stringdb-link.readthedocs.io) -- **Email**: dev@stringdb-link.org - ---- - -Made with ❤️ for the scientific community +uv sync --group dev +make dev # REST + MCP on http://127.0.0.1:8000/mcp +curl -s localhost:8000/health +claude mcp add --transport http stringdb http://127.0.0.1:8000/mcp +``` + +> [!NOTE] +> MCP clients need the `unified` transport (what `make dev` runs). `--transport http` serves +> the REST API **without** `/mcp`. See [deployment.md](docs/deployment.md#transports). + +## Tools + +| Tool | Purpose | +|------|---------| +| `resolve_protein_identifiers` | Map gene symbols, synonyms or UniProt accessions to STRING protein IDs | +| `search_protein_interactions` | Retrieve the interaction network among a set of proteins | +| `get_interaction_partners` | List a protein's STRING interaction partners | +| `compute_functional_enrichment` | Enrichment over GO, KEGG, UniProt keywords, PubMed, Pfam, InterPro and SMART | +| `compute_ppi_enrichment` | Test whether a protein set has more interactions than expected by chance | +| `get_functional_annotations` | Retrieve the functional annotations attached to each protein | +| `get_protein_homology_scores` | Pairwise protein similarity (bit-scores) among the input proteins | +| `get_protein_homology_best_hits` | Best similarity hit per species for the input proteins | +| `get_network_link` | Build a shareable link to the network on the STRING website | +| `get_network_image` | Render the network as an image (PNG, high-res PNG, or SVG) | + +Leaf names are **unprefixed**, per Tool-Naming Standard v1. Behind +[`genefoundry-router`](https://github.com/berntpopp/genefoundry-router) the server is mounted +under the `stringdb` namespace, so tools surface as `stringdb_` — e.g. +`stringdb_get_interaction_partners`. Do not self-prefix tool names; the gateway adds the +namespace (a CI guard enforces this). + +## Data & provenance + +Data come live from the STRING REST API, pinned to **STRING v12.0** +(`https://version-12-0.string-db.org/api`) so a query keeps its answer across STRING +releases. There is no local mirror; freshness is STRING's, minus a per-result-class cache +TTL (24 h for identifier mappings, 12 h for networks, 6 h for enrichment). + +STRING asks callers to wait one second between requests; the client throttles to 1 req/s by +default and identifies itself with `caller_identity`. **Do not bypass the throttle.** + +STRING data are licensed **[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)**. +Cite STRING when publishing results derived from it: + +> Szklarczyk D, Kirsch R, Koutrouli M, et al. The STRING database in 2023: protein–protein +> association networks and functional enrichment analyses for any sequenced genome of +> interest. *Nucleic Acids Res.* 2023;51(D1):D638–D646. doi:10.1093/nar/gkac1000 + +Full detail — endpoint map, TTL rationale, identifier semantics: [data.md](docs/data.md). + +## Documentation + +- [Configuration](docs/configuration.md) — every environment variable, the Host/Origin + request guards, CORS, cache TTLs and the MCP identity contract. +- [Deployment](docs/deployment.md) — transports, entry points, Docker, running behind a + reverse proxy, and Claude Desktop wiring. +- [Architecture](docs/architecture.md) — how the MCP surface is generated from the REST + routes, the MCP hardening layers, and the REST API with examples. +- [Data & provenance](docs/data.md) — STRING sources, the v12.0 pin, caching, licence and + citation. +- [STRING API reference](docs/rest-api.md) — the upstream API docs, vendored. +- [SECURITY.md](SECURITY.md) — vulnerability reporting and required repository settings. +- [AGENTS.md](AGENTS.md) — repository conventions for humans and coding agents. + +## Contributing + +See [AGENTS.md](AGENTS.md) for engineering conventions (uv, Ruff, mypy strict, the 600-line +module budget, test layout). `make ci-local` is the definition-of-done gate: format, lint, +line budget, README standard, typecheck, and tests. It must be green before handoff. + +## License + +Code: [MIT](LICENSE) © Bernt Popp. STRING data: **CC BY 4.0** © the STRING Consortium — +attribution required, and any changes or additions you make must be stated. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..758cd35 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,119 @@ +# Architecture + +`stringdb-link` is one FastAPI application with two faces: a REST API and an MCP server. The +MCP surface is **generated from the REST routes**, which is why the two never drift apart. + +## Layout + +``` +stringdb_link/ +├── api/ +│ ├── client.py # STRING HTTP client: throttling, retries, caching, redirect guard +│ ├── url_guard.py # Host allowlist for upstream URLs and redirects +│ ├── http_errors.py # Upstream error → typed exception mapping +│ └── routes/ # FastAPI routes (identifiers, networks, enrichment, +│ # annotations, homology, images, health) +├── services/ # Business logic between routes and the client +├── models/ # Pydantic request/response models + STRING enums +├── mcp/ # MCP-only concerns (see below) +├── config.py # Settings facade +├── config_models.py # Nested Pydantic settings models +├── exceptions.py # Exception taxonomy +└── logging_config.py # Structured logging (structlog) +``` + +Built on FastAPI, FastMCP, Pydantic v2 and httpx. Per `AGENTS.md`, Python modules are capped +at 600 lines; the four grandfathered files are listed in `.loc-allowlist` and may shrink but +not grow. + +## REST → MCP generation + +`create_mcp_app()` (`app.py`) builds the MCP server with `FastMCP.from_fastapi(...)`. Tool +names are taken **verbatim from each route's `operation_id`** — so a route's `operation_id` +*is* its MCP tool name, and adding a route can silently add a tool. Two guards exist: + +- `tests/unit/test_tool_names.py` — every registered tool must be unprefixed snake_case, + ≤ 50 chars, and start with a canonical verb (Tool-Naming Standard v1.1). +- `tests/unit/test_readme_tools.py` — the README's Tools table must equal the registered + tool set exactly. + +Not every route becomes a tool. `RouteMap` exclusions in `create_mcp_app()` keep the MCP +surface curated: + +| Excluded | Why | +|----------|-----| +| `/health`, `/api/health*`, `/api/version`, `/`, `/docs`, `/redoc`, `/openapi.json` | Operational, not domain surface. | +| Single-identifier GET convenience routes (`/api/identifiers/resolve/{id}`, `/api/networks/interactions/{id}`, `/api/networks/partners/{id}`) | Duplicate the list-based POST tools. | +| Raw bulk-download routes (`/api/homology/*/download`) | Non-canonical verb, poor MCP ergonomics, redundant with the JSON tools. | + +`mask_error_details=True` keeps internal exception text out of MCP error responses. + +## MCP hardening layers (`stringdb_link/mcp/`) + +| Module | Responsibility | +|--------|----------------| +| `envelope.py` | Response-Envelope Standard v1 — flat success banner / flat in-band error frame. | +| `error_passthrough.py` | Wraps every generated OpenAPI tool so its output is an envelope; reshapes the MCP surface only, never REST. | +| `untrusted_content.py` | Fences upstream free text so retrieved content reads as evidence, not instructions. | +| `notfound_guard.py` | Stops FastMCP-core from reflecting a caller's unknown tool name / resource URI back into responses and logs. | +| `annotations.py` | Tool annotations (read-only, open-world). | + +## Federation contract + +- `serverInfo.name` is **`stringdb-link`** — asserted by the conformance gate + (`.github/workflows/conformance.yml`) and by the router's registry. +- `serverInfo.version` is single-sourced from installed package metadata. +- Leaf tool names are deliberately **unprefixed** (`get_interaction_partners`, not + `stringdb_get_interaction_partners`). The canonical gateway namespace token is + **`stringdb`**; `genefoundry-router` applies it at mount time, so tools surface as + `stringdb_`. Self-prefixing would double-prefix at the gateway — the tool-name guard + test rejects it. +- Transport is Streamable HTTP at `/mcp` (stateless JSON); stdio is available locally. + +## HTTP REST surface + +The REST API is a first-class surface, not a by-product. Interactive docs, once the server +is running: + +- Swagger UI — `http://localhost:8000/docs` +- ReDoc — `http://localhost:8000/redoc` +- OpenAPI JSON — `http://localhost:8000/openapi.json` + +Resolve identifiers: + +```bash +curl -X POST "http://localhost:8000/api/identifiers/resolve" \ + -H "Content-Type: application/json" \ + -d '{"identifiers": ["p53", "BRCA1", "cdk2"], "species": 9606, "echo_query": true}' +``` + +Get interactions: + +```bash +curl -X POST "http://localhost:8000/api/networks/interactions" \ + -H "Content-Type: application/json" \ + -d '{"identifiers": ["TP53", "MDM2", "ATM"], "species": 9606, + "required_score": 0.4, "network_type": "functional"}' +``` + +`required_score` is a **normalized confidence in `0.0`–`1.0`** (default `0.4` = STRING's +"medium" threshold), not STRING's raw 0–1000 integer; the client rescales it upstream. + +Functional enrichment: + +```bash +curl -X POST "http://localhost:8000/api/enrichment/functional" \ + -H "Content-Type: application/json" \ + -d '{"identifiers": ["TP53", "MDM2", "ATM", "CHEK2", "BRCA1"], "species": 9606}' +``` + +## Domain vocabulary + +| Concept | Values | +|---------|--------| +| `species` | NCBI taxon ID (`9606` = human). | +| `network_type` | `functional`, `physical`. | +| Image format | PNG (`image`), high-resolution PNG (`highres_image`), `svg`. | +| Output format | `json`, `tsv`, `tsv-no-header`, `xml`, `psi-mi`, `psi-mi-tab`. | + +Upstream endpoints, caching TTLs and rate-limit etiquette: [`data.md`](data.md). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..ac30387 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,135 @@ +# Configuration + +Every setting is an environment variable, read from the process environment or a `.env` file +in the working directory. [`../.env.example`](../.env.example) is the copy-pasteable +template; this page explains what the values mean. + +Settings are Pydantic models with the **nested delimiter `__`**: a variable addresses a +section and a field, e.g. `CACHE__IDENTIFIER_TTL` sets `cache.identifier_ttl`. Server-level +settings are flat (`HOST`, `PORT`, `TRANSPORT`). Unknown variables are ignored, so a +misspelled name fails silently — copy the names below exactly. + +List-valued variables (`ALLOWED_HOSTS`, `ALLOWED_ORIGINS`, the `CORS__*` lists) are parsed as +**JSON arrays**: `ALLOWED_HOSTS=["localhost","127.0.0.1","::1"]`. + +## Server + +| Variable | Default | Notes | +|----------|---------|-------| +| `HOST` | `127.0.0.1` | Bind address. Keep loopback behind a reverse proxy. | +| `PORT` | `8000` | Bind port. | +| `TRANSPORT` | `unified` | `unified` (REST + MCP), `http` (REST only), or `stdio`. See [deployment.md](deployment.md). | +| `RELOAD` | `false` | Auto-reload; development only. | +| `DEBUG` / `DEVELOPMENT_MODE` | `false` | Either one puts the app in development mode. | + +## Request guards (Host / Origin) + +Every HTTP route is gated by **exact** allowlists. These are request-admission guards, and +they are independent of the CORS *response* policy below. + +| Variable | Default | Notes | +|----------|---------|-------| +| `ALLOWED_HOSTS` | `["localhost","127.0.0.1","::1"]` | Exact `Host` header values. **Wildcard patterns (`*`, `?`, `[]`) are rejected** — the config raises rather than accept an ambiguous boundary. | +| `ALLOWED_ORIGINS` | `[]` | Exact browser `Origin` values. Empty still admits requests that carry **no** `Origin` header (i.e. non-browser clients). | + +When deploying behind a reverse proxy, **add the public hostname to `ALLOWED_HOSTS`** — e.g. +`["stringdb-link.genefoundry.org","localhost","127.0.0.1","::1"]`. Forgetting this is the +usual cause of a proxied deployment returning errors while `curl localhost` works. + +## CORS (browser response policy) + +| Variable | Default | Notes | +|----------|---------|-------| +| `CORS__ALLOW_ORIGINS` | `[]` | Origins echoed in CORS response headers. | +| `CORS__ALLOW_CREDENTIALS` | `false` | Off by design: this backend is unauthenticated and holds no cookies or sessions. | +| `CORS__ALLOW_METHODS` | `["GET","POST","PUT","DELETE","OPTIONS"]` | | +| `CORS__ALLOW_HEADERS` | `["*"]` | | + +`CORS__ALLOW_CREDENTIALS=true` combined with a wildcard origin **fails startup**: the CORS +spec forbids the combination and browsers reject it, so it can only ever be a footgun. + +## STRING API client + +| Variable | Default | Notes | +|----------|---------|-------| +| `STRINGDB_API__BASE_URL` | `https://version-12-0.string-db.org/api` | **Pinned to STRING v12.0** for reproducibility — see [data.md](data.md). | +| `STRINGDB_API__RATE_LIMIT_PER_SECOND` | `1.0` | STRING asks callers to wait one second between calls. Do not raise it. | +| `STRINGDB_API__TIMEOUT` | `30` | Seconds. | +| `STRINGDB_API__MAX_RETRIES` | `3` | Retries use exponential backoff. | +| `STRINGDB_API__RETRY_DELAY` | `1.0` | Base backoff delay, seconds. | +| `STRINGDB_API__CALLER_IDENTITY` | `StringDB-Link/…` | Sent to STRING on every call, as STRING requests. | +| `STRINGDB_API__REDIRECT_BASE_URL` | `https://string-db.org` | The only STRING origin redirects are allowed to target. | + +## Caching + +| Variable | Default | Notes | +|----------|---------|-------| +| `CACHE__ENABLED` | `true` | | +| `CACHE__DEFAULT_TTL` | `3600` | Seconds. | +| `CACHE__IDENTIFIER_TTL` | `86400` | 24 h — identifier mappings are stable. | +| `CACHE__NETWORK_TTL` | `43200` | 12 h. | +| `CACHE__ENRICHMENT_TTL` | `21600` | 6 h. | +| `CACHE__IMAGE_TTL` | `7200` | 2 h. | +| `CACHE__MAX_SIZE` | `1000` | Entries. | + +The tiering is deliberate: see [data.md](data.md#freshness--caching). + +## Performance + +| Variable | Default | +|----------|---------| +| `PERFORMANCE__MAX_CONCURRENT_REQUESTS` | `100` | +| `PERFORMANCE__CONNECTION_POOL_SIZE` | `20` | +| `PERFORMANCE__CONNECTION_POOL_MAX_SIZE` | `100` | +| `PERFORMANCE__KEEPALIVE_TIMEOUT` | `5` | + +## Security + +| Variable | Default | Notes | +|----------|---------|-------| +| `SECURITY__API_KEY_REQUIRED` | `false` | Optional API-key gate on the REST surface. | +| `SECURITY__API_KEY_HEADER` | `X-API-Key` | | +| `SECURITY__RATE_LIMIT_ENABLED` | `true` | Inbound (caller-facing) rate limiting. | +| `SECURITY__RATE_LIMIT_REQUESTS` | `100` | Requests per window. | +| `SECURITY__RATE_LIMIT_WINDOW` | `60` | Window, seconds. | + +This backend is **unauthenticated by design** — the `genefoundry-router` / reverse proxy owns +edge auth at the trust boundary. It must never be published directly. See +[`../SECURITY.md`](../SECURITY.md). + +## Logging + +| Variable | Default | Notes | +|----------|---------|-------| +| `LOGGING__LEVEL` | `INFO` | `DEBUG` … `CRITICAL`. | +| `LOGGING__FORMAT` | `text` | `text` or `json`. | +| `LOGGING__FILE_ENABLED` | `false` | Plus `FILE_PATH`, `FILE_MAX_SIZE`, `FILE_BACKUP_COUNT`. | + +Logging is structured (structlog). A redaction processor +(`stringdb_link/logging_config.py`, `redact_sensitive_processor`) strips sensitive values +from every event, so secrets do not reach the logs; it is guarded by +`tests/unit/test_logging_redaction.py`. + +## MCP + +| Variable | Default | Notes | +|----------|---------|-------| +| `MCP__PATH` | `/mcp` | Streamable-HTTP endpoint path. | +| `MCP__SERVER_NAME` | `stringdb-link` | Advertised as `serverInfo.name` on `initialize`. | + +> [!WARNING] +> `MCP__SERVER_NAME` is a **federation identity contract**, not cosmetics. The conformance +> gate (`.github/workflows/conformance.yml`, `CONFORMANCE_NAME: stringdb-link`) and the +> router's registry both assert `serverInfo.name == stringdb-link`. Overriding it breaks +> discovery behind the gateway. Note that `.env.example` currently ships a different value +> (`StringDB-Link Server`); the code default `stringdb-link` is the ratified one. + +`serverInfo.version` is single-sourced from the installed package metadata +(`stringdb_link.__version__`), guarded by `tests/unit/test_version_single_source.py`. + +## Checking a configuration + +```bash +uv run stringdb-link config # print the resolved settings +uv run stringdb-link validate-config # validate and exit non-zero on error +``` diff --git a/docs/data.md b/docs/data.md new file mode 100644 index 0000000..1564011 --- /dev/null +++ b/docs/data.md @@ -0,0 +1,104 @@ +# Data & provenance + +Upstream source, freshness model, licensing and citation for the data this server serves. +Research use only; not clinical decision support. + +## Upstream + +All data are fetched live from the [STRING](https://string-db.org/) REST API. There is **no +local data bundle, no ingest step and no build step** — `stringdb-link` is a typed, cached, +rate-limited proxy in front of STRING, so it serves immediately after install. + +The base URL is **pinned to STRING v12.0**: + +```env +STRINGDB_API__BASE_URL=https://version-12-0.string-db.org/api +``` + +The pin is deliberate and load-bearing. STRING's own API guidance is explicit about it: + +> When developing your tool use default STRING address (`https://string-db.org`), but when +> your code is ready, you should link to a specific STRING version (for example +> `https://version-12-0.string-db.org`), which will ensure that for the same query you will +> always get the same API response, even after STRING or API gets updated. + +Repointing the base URL at the unversioned host makes results non-reproducible across STRING +releases. Bump the pin deliberately, as a reviewed change, when adopting a new STRING release. + +A verbatim copy of STRING's API reference is vendored at [`rest-api.md`](rest-api.md). + +## Upstream endpoints used + +The client maps each tool onto one STRING API endpoint +(`stringdb_link/config_models.py`, `StringDBAPIConfigModel.endpoints`): + +| Purpose | STRING endpoint | +|---------|-----------------| +| Identifier resolution | `json/get_string_ids` | +| Interaction network | `json/network` | +| Interaction partners | `json/interaction_partners` | +| Functional enrichment | `json/enrichment` | +| Functional annotation | `json/functional_annotation` | +| PPI enrichment | `json/ppi_enrichment` | +| Homology scores | `json/homology` | +| Best homology hits | `json/homology_best` | +| Network image | `image/network` | +| Enrichment figure | `image/enrichment` | + +Enrichment covers Gene Ontology, KEGG pathways, UniProt keywords, PubMed publications, and +Pfam / InterPro / SMART domains. + +## Freshness & caching + +Freshness follows STRING: a given pinned version is a static release, and the server holds +results only for the TTL of its in-process cache. The TTLs are tiered by how stable each +result class is — identifier mappings change far more slowly than networks or enrichment: + +| Result class | Variable | Default | +|--------------|----------|---------| +| Identifier mappings | `CACHE__IDENTIFIER_TTL` | `86400` (24 h) | +| Networks & partners | `CACHE__NETWORK_TTL` | `43200` (12 h) | +| Enrichment | `CACHE__ENRICHMENT_TTL` | `21600` (6 h) | +| Images | `CACHE__IMAGE_TTL` | `7200` (2 h) | +| Anything else | `CACHE__DEFAULT_TTL` | `3600` (1 h) | + +See [`configuration.md`](configuration.md) for the full cache and rate-limit settings. + +## Rate-limit etiquette + +STRING asks callers to *"be considerate and wait one second between each call, so that our +server won't get overloaded"*. The client therefore throttles to **1 request/second** by +default (`STRINGDB_API__RATE_LIMIT_PER_SECOND=1.0`). + +**Do not bypass or raise this throttle** to work around slowness — it is the courtesy +contract with a free public service, and `AGENTS.md` forbids it ("Respect STRING API rate +limits; do not bypass the existing client throttling"). + +STRING also asks callers to identify themselves via the `caller_identity` parameter; the +client sends it on every request (`STRINGDB_API__CALLER_IDENTITY`). + +## Identifier semantics + +STRING keys everything on STRING protein IDs (e.g. `9606.ENSP00000269305`), not on gene +symbols. `resolve_protein_identifiers` is the front door: it maps symbols, synonyms and +UniProt accessions onto STRING IDs so every other tool has a stable key. `species` is an +**NCBI taxon ID** (`9606` = human). + +## Licence + +STRING data are released under **[Creative Commons BY 4.0](https://creativecommons.org/licenses/by/4.0/)**: + +> All data and download files in STRING are freely available under a "Creative Commons BY +> 4.0" license. + +When using the data, provide appropriate credit and state any changes or additions you made. +This is separate from the licence on *this server's code*, which is MIT (see +[`../LICENSE`](../LICENSE)). + +## Citation + +Cite STRING when you publish results derived from it: + +> Szklarczyk D, Kirsch R, Koutrouli M, et al. The STRING database in 2023: protein–protein +> association networks and functional enrichment analyses for any sequenced genome of +> interest. *Nucleic Acids Res.* 2023;51(D1):D638–D646. doi:10.1093/nar/gkac1000 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..72813d8 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,132 @@ +# Deployment + +How to run `stringdb-link` — locally, in Docker, and behind a reverse proxy — plus the MCP +client wiring. Configuration values referenced here are defined in +[`configuration.md`](configuration.md). + +## Entry points + +Two console scripts ship with the package (`pyproject.toml` → `[project.scripts]`): + +| Script | Module | Purpose | +|--------|--------|---------| +| `stringdb-link` | `stringdb_link.cli:main` | The CLI: `server`, `mcp`, `config`, `validate-config`, `health`, `version`. | +| `stringdb-mcp` | `mcp_server:main` | Direct **stdio** MCP entry point (Claude Desktop and other stdio hosts). | + +## Transports + +This server is tri-modal — it can serve REST only, MCP only, or both from one process. + +| Mode | Command | Serves | +|------|---------|--------| +| `unified` (default) | `stringdb-link server --transport unified --port 8000` | FastAPI REST **and** MCP over Streamable HTTP at `/mcp` | +| `http` | `stringdb-link server --transport http --port 8000` | FastAPI REST only | +| `stdio` | `stringdb-link mcp` (or `python mcp_server.py`) | MCP over stdio | + +> [!IMPORTANT] +> MCP clients need **`unified`**. `--transport http` does not expose `/mcp`. This is the +> usual cause of a "server added but no tools" symptom behind the router. + +Make targets wrap the common cases: + +```bash +make dev # unified, 127.0.0.1:8000, --reload +make mcp-serve-http # unified, 0.0.0.0:8000 (container / proxied form) +make mcp-serve # stdio +``` + +## Health + +| Path | Purpose | +|------|---------| +| `GET /health` | Root-level probe. Required by the MCP Transport Standard v1 conformance probe and used by the Docker health check. | +| `GET /api/health` | Detailed health, including upstream STRING reachability. | +| `GET /api/version` | Version metadata. | + +`stringdb-link health` performs the check from the CLI. + +## Docker + +Compose files live in [`../docker/`](../docker); see [`../docker/README.md`](../docker/README.md) +for the image internals and file-by-file breakdown. + +```bash +make docker-build # docker compose -f docker/docker-compose.yml build +make docker-up # start +make docker-logs # tail +make docker-down # stop +``` + +| File | Use | +|------|-----| +| `docker/docker-compose.yml` | Base / development stack. | +| `docker/docker-compose.dev.yml` | Hot-reload overlay. | +| `docker/docker-compose.prod.yml` | Production: Gunicorn, digest-pinned image, resource limits, no published ports. | +| `docker/docker-compose.npm.yml` | Production behind an external Nginx Proxy Manager network. | + +Environment templates: [`../.env.example`](../.env.example) (local), +[`../.env.docker.example`](../.env.docker.example) (container), +[`../.env.npm.example`](../.env.npm.example) (proxied production). + +The container follows the fleet's Container & Deployment Hardening Standard v1: non-root, +read-only root filesystem, all capabilities dropped, `no-new-privileges`, resource limits, +digest-pinned base image, and CI image scanning. Release metadata is pinned in +[`../container-release.json`](../container-release.json). + +## Behind a reverse proxy + +The backend is **unauthenticated by design**. The `genefoundry-router` (or your own reverse +proxy) is the trust boundary and owns edge auth. Two consequences: + +1. **Never publish the container port directly.** In production, bind loopback or expose the + port only on the proxy's internal network (`docker-compose.prod.yml` publishes nothing). +2. **Add the public hostname to `ALLOWED_HOSTS`**, e.g. + `ALLOWED_HOSTS=["stringdb-link.genefoundry.org","localhost","127.0.0.1","::1"]`. + The Host guard takes exact values; wildcards are rejected. A proxied deployment that + works on `localhost` but fails through the proxy is nearly always this. + +TLS terminates at the proxy. Set `ALLOWED_ORIGINS` only if a browser origin must reach the +server directly; it is empty by default, which still admits non-browser clients (no `Origin` +header). + +## MCP client wiring + +Hosted (Streamable HTTP): + +```bash +claude mcp add --transport http stringdb https://stringdb-link.genefoundry.org/mcp +``` + +Local (Streamable HTTP), after `make dev`: + +```bash +claude mcp add --transport http stringdb http://127.0.0.1:8000/mcp +``` + +Claude Desktop (stdio) — `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "stringdb-link": { + "command": "stringdb-link", + "args": ["mcp"], + "env": { + "STRINGDB_API__RATE_LIMIT_PER_SECOND": "1.0" + } + } + } +} +``` + +Behind `genefoundry-router` the server is mounted under the `stringdb` namespace and its +tools surface as `stringdb_` — see [`architecture.md`](architecture.md#federation-contract). + +## Operator follow-ups + +[`../SECURITY.md`](../SECURITY.md) documents the vulnerability-reporting process **and one +outstanding operator action**: GitHub secret scanning and push protection are repository +settings, not workflow files, so they cannot be enabled from a pull request. An admin must +enable them with `gh api -X PATCH repos/berntpopp/stringdb-link …` (exact command in +`SECURITY.md`). Code scanning (CodeQL) is already wired via +`.github/workflows/security.yml`. diff --git a/scripts/check_readme.py b/scripts/check_readme.py new file mode 100644 index 0000000..3a1709f --- /dev/null +++ b/scripts/check_readme.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +"""Enforce the GeneFoundry README Standard v1. + +One linter, copied verbatim into every fleet repo. It checks the things a reader +notices and a maintainer forgets: length, section order, the badge row, the +research-use callout, link integrity, and hand-typed facts that rot. + +See ``docs/README-STANDARD-v1.md`` (in genefoundry-router) for the rationale. + +Repo class is inferred, not configured: a backend ships +``.github/workflows/conformance.yml``; the router does not (its slot-3 badge is +``security.yml``). Content inside ```` / +```` markers is exempt from the hand-typed-fact rule, +because a test owns it. + +Exits non-zero on any violation. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +README = ROOT / "README.md" + +LINE_CEILING = 200 + +REQUIRED_SECTIONS = [ + "Why", + "Quick start", + "Tools", + "Data & provenance", + "Documentation", + "Contributing", + "License", +] + +RUO_CALLOUT = ( + "> [!IMPORTANT]\n" + "> Research use only. Not clinical decision support. Do not use for diagnosis,\n" + "> treatment, triage, or patient management." +) + +# Derived facts that drift silently. Enumerations are fine; aggregates are not. +FORBIDDEN_FACTS = [ + (re.compile(r"\b\d+\s+tools?\b", re.I), "hand-typed tool count"), + (re.compile(r"\b\d+\s+(?:tests?|passing)\b", re.I), "hand-typed test count"), + (re.compile(r"\bcoverage[:\s]+\d+\s*%", re.I), "hand-typed coverage"), + (re.compile(r"\b\d+(?:\.\d+)?\s*/\s*10\b"), "self-awarded score"), +] + +GENERATED_BLOCK = re.compile( + r".*?", + re.S, +) + + +def repo_slug() -> str: + return ROOT.name + + +def is_router() -> bool: + return not (ROOT / ".github/workflows/conformance.yml").exists() + + +def expected_badges(slug: str) -> list[tuple[str, str]]: + """(label, substring that must appear in the badge line), in order.""" + gate = "security.yml" if is_router() else "conformance.yml" + gate_label = "Security" if is_router() else "Conformance" + return [ + ("Python 3.12+", "img.shields.io/badge/python-3.12"), + ("CI", f"/{slug}/actions/workflows/ci.yml/badge.svg"), + (gate_label, f"/{slug}/actions/workflows/{gate}/badge.svg"), + ("License: MIT", "img.shields.io/badge/license-MIT"), + ] + + +def check_length(lines: list[str], errors: list[str]) -> None: + if len(lines) > LINE_CEILING: + errors.append( + f"README is {len(lines)} lines; ceiling is {LINE_CEILING}. " + f"Move content to docs/ (see README-STANDARD-v1 relocation table)." + ) + + +def check_title(lines: list[str], errors: list[str]) -> None: + h1s = [ln for ln in lines if ln.startswith("# ")] + if len(h1s) != 1: + errors.append(f"expected exactly one H1, found {len(h1s)}") + return + if not lines or not lines[0].startswith("# "): + errors.append("the H1 must be the first line") + + +def check_badges(text: str, lines: list[str], errors: list[str]) -> None: + badge_lines = [ln for ln in lines if "badge.svg" in ln or "img.shields.io" in ln] + want = expected_badges(repo_slug()) + if len(badge_lines) != len(want): + errors.append( + f"expected exactly {len(want)} badges, found {len(badge_lines)}. " + f"Canonical row: {', '.join(label for label, _ in want)}." + ) + return + for i, ((label, needle), got) in enumerate(zip(want, badge_lines), start=1): + if needle not in got: + errors.append( + f"badge {i} should be {label!r} (expected {needle!r} in the URL); got: {got.strip()[:80]}" + ) + if "?branch=" in text: + errors.append("badge URLs must not pin ?branch= — they default to the default branch") + + +def check_sections(lines: list[str], errors: list[str]) -> None: + found = [ln[3:].strip() for ln in lines if ln.startswith("## ")] + if found != REQUIRED_SECTIONS: + errors.append( + "H2 sections must be exactly, in order:\n" + f" expected: {REQUIRED_SECTIONS}\n" + f" found: {found}" + ) + + +def check_callout(text: str, errors: list[str]) -> None: + if RUO_CALLOUT not in text: + errors.append( + "missing or reworded research-use callout. It must appear verbatim:\n" + + "\n".join(f" {ln}" for ln in RUO_CALLOUT.splitlines()) + ) + + +def check_links(text: str, errors: list[str]) -> None: + for match in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", text): + target = match.group(1).split("#", 1)[0].strip() + if not target or target.startswith(("http://", "https://", "mailto:")): + continue + if not (ROOT / target).exists(): + errors.append(f"broken relative link: {target!r} does not exist") + + +def check_facts(text: str, errors: list[str]) -> None: + scrubbed = GENERATED_BLOCK.sub("", text) + for pattern, what in FORBIDDEN_FACTS: + for m in pattern.finditer(scrubbed): + errors.append( + f"{what}: {m.group(0)!r} — a hand-typed derived fact rots. " + f"Generate it inside a GENERATED block (a test must own it), or drop it." + ) + + +def main() -> int: + if not README.exists(): + print("error: README.md not found", file=sys.stderr) + return 1 + + text = README.read_text(encoding="utf-8") + lines = text.splitlines() + errors: list[str] = [] + + check_length(lines, errors) + check_title(lines, errors) + check_badges(text, lines, errors) + check_sections(lines, errors) + check_callout(text, errors) + check_links(text, errors) + check_facts(text, errors) + + if errors: + klass = "router" if is_router() else "backend" + print( + f"README Standard v1 violations in {repo_slug()} ({klass}):\n", + file=sys.stderr, + ) + for err in errors: + print(f" - {err}", file=sys.stderr) + print( + "\nSee docs/README-STANDARD-v1.md in genefoundry-router.", + file=sys.stderr, + ) + return 1 + + print(f"README Standard v1: OK ({len(lines)} lines, ceiling {LINE_CEILING})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_readme_tools.py b/tests/unit/test_readme_tools.py new file mode 100644 index 0000000..96f337b --- /dev/null +++ b/tests/unit/test_readme_tools.py @@ -0,0 +1,64 @@ +"""Guard: the README '## Tools' table matches the registered MCP tool surface. + +The README is the front door: a reader decides whether this server is worth adding +from that table alone. Tool names here are generated by ``FastMCP.from_fastapi`` +from each route's ``operation_id``, so adding a route silently adds a tool — and a +hand-maintained table would drift the moment it happened. + +This test closes that gap: it parses the table out of the README and asserts its +tool names equal the server's registered tools exactly, using the same ``facade`` +fixture as ``test_tool_names.py``. Adding a tool without documenting it fails CI. + +See the GeneFoundry README Standard v1 (docs/README-STANDARD-v1.md in +genefoundry-router), Rule 6. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +README = Path(__file__).resolve().parents[2] / "README.md" + +_TOOLS_TABLE_ROW = re.compile(r"^\|\s*`(?P[a-z0-9_]+)`\s*\|") + + +def _readme_tool_names() -> set[str]: + """Tool names listed in the README's '## Tools' table.""" + lines = README.read_text(encoding="utf-8").splitlines() + + try: + start = lines.index("## Tools") + except ValueError: # pragma: no cover - defended by the assertion below + return set() + + names: set[str] = set() + for line in lines[start + 1 :]: + if line.startswith("## "): # next section: the table is over + break + match = _TOOLS_TABLE_ROW.match(line) + if match: + names.add(match.group("name")) + return names + + +async def test_readme_tools_table_matches_registered_tools(facade: Any) -> None: + registered = {t.name for t in await facade.list_tools()} + assert registered, "no tools registered on the facade" + + documented = _readme_tool_names() + assert documented, "no tools found in the README '## Tools' table" + + missing_from_readme = registered - documented + stale_in_readme = documented - registered + + assert not missing_from_readme, ( + f"tools are registered but missing from the README '## Tools' table: " + f"{sorted(missing_from_readme)} — document them (one row each)" + ) + assert not stale_in_readme, ( + f"the README '## Tools' table lists tools that are not registered: " + f"{sorted(stale_in_readme)} — remove the stale rows" + ) + assert documented == registered From b3b30d6ee34db5c64da16ea07bc5ddcc021b4790 Mon Sep 17 00:00:00 2001 From: Bernt Popp Date: Tue, 14 Jul 2026 08:49:37 +0200 Subject: [PATCH 2/2] docs: correct false config facts and machine-check the env table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README Standard v1 refactor introduced factual errors in docs/configuration.md — the exact rot the standard exists to prevent. Corrections (verified against the live settings model): - CORS__ALLOW_ORIGINS default is ["http://localhost:3000","http://127.0.0.1:3000"], not []. The dev defaults are inert in production (the ALLOWED_ORIGINS request guard rejects those origins before CORS applies), but the documented fact was false. - LOGGING__FORMAT default is json, not text. - README claimed configuration.md documents "every environment variable"; it omitted 8 of 48 (HEALTH_CHECK__*, STRINGDB_API__USER_AGENT/ENDPOINTS, the LOGGING__FILE_* rows). The claim is now true rather than softened. - STRINGDB_API__CALLER_IDENTITY default is exact, not elided. Sources of truth fixed, not just described: - .env.example / .env.npm.example shipped MCP__SERVER_NAME="StringDB-Link Server", so `cp .env.example .env` silently broke the ratified serverInfo.name federation contract that the conformance gate and the router registry both assert. They now ship stringdb-link. - Both templates shipped CORS__ALLOW_CREDENTIALS=true on a backend documented as unauthenticated-by-design ("off by design"), contradicting the doc and the fleet CORS standard. Now false. tests/unit/test_config_docs_contract.py pins all of it: the doc table must be exhaustive over the settings model and every documented default must equal the code default; env-template keys must be real settings (extra="ignore" drops typos silently) or compose-interpolated; templates may not override serverInfo.name or enable credentialed CORS. Mutation-checked — reinstating the [] default or dropping a row fails the suite. make ci-local green (452 passed); scripts/check_readme.py exits 0. Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 +- .env.npm.example | 4 +- README.md | 5 +- docs/configuration.md | 29 +++- scripts/check_readme.py | 65 ++++++--- tests/unit/test_config_docs_contract.py | 185 ++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 tests/unit/test_config_docs_contract.py diff --git a/.env.example b/.env.example index b515686..2c5bfb8 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ LOGGING__FORMAT=text CORS__ALLOW_ORIGINS=["http://localhost:3000","http://localhost:8080"] CORS__ALLOW_METHODS=["GET","POST","PUT","DELETE","OPTIONS"] CORS__ALLOW_HEADERS=["*"] -CORS__ALLOW_CREDENTIALS=true +CORS__ALLOW_CREDENTIALS=false # STRING API Configuration STRINGDB_API__BASE_URL=https://version-12-0.string-db.org/api @@ -52,4 +52,4 @@ SECURITY__RATE_LIMIT_WINDOW=60 # MCP Configuration MCP__PATH=/mcp -MCP__SERVER_NAME=StringDB-Link Server +MCP__SERVER_NAME=stringdb-link diff --git a/.env.npm.example b/.env.npm.example index b83bd82..1bc0701 100644 --- a/.env.npm.example +++ b/.env.npm.example @@ -24,7 +24,7 @@ LOGGING__FORMAT=json CORS__ALLOW_ORIGINS=["https://stringdb.yourdomain.com"] CORS__ALLOW_METHODS=["GET","POST","PUT","DELETE","OPTIONS"] CORS__ALLOW_HEADERS=["*"] -CORS__ALLOW_CREDENTIALS=true +CORS__ALLOW_CREDENTIALS=false # STRING API Configuration STRINGDB_API__BASE_URL=https://version-12-0.string-db.org/api @@ -57,4 +57,4 @@ SECURITY__RATE_LIMIT_WINDOW=60 # MCP Configuration MCP__PATH=/mcp -MCP__SERVER_NAME=StringDB-Link Server +MCP__SERVER_NAME=stringdb-link diff --git a/README.md b/README.md index 99edf14..e545d50 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,9 @@ Full detail — endpoint map, TTL rationale, identifier semantics: [data.md](doc ## Documentation -- [Configuration](docs/configuration.md) — every environment variable, the Host/Origin - request guards, CORS, cache TTLs and the MCP identity contract. +- [Configuration](docs/configuration.md) — every environment variable (the tables are + machine-checked against the live settings model), the Host/Origin request guards, CORS, + cache TTLs and the MCP identity contract. - [Deployment](docs/deployment.md) — transports, entry points, Docker, running behind a reverse proxy, and Claude Desktop wiring. - [Architecture](docs/architecture.md) — how the MCP surface is generated from the REST diff --git a/docs/configuration.md b/docs/configuration.md index ac30387..1388fd6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,10 @@ misspelled name fails silently — copy the names below exactly. List-valued variables (`ALLOWED_HOSTS`, `ALLOWED_ORIGINS`, the `CORS__*` lists) are parsed as **JSON arrays**: `ALLOWED_HOSTS=["localhost","127.0.0.1","::1"]`. +The tables below are **exhaustive and machine-checked**: `tests/unit/test_config_docs_contract.py` +walks the live settings model (`stringdb_link/config.py`) and fails if this page omits a +variable or states a default the code does not set. A row here is a fact, not a description. + ## Server | Variable | Default | Notes | @@ -40,7 +44,7 @@ usual cause of a proxied deployment returning errors while `curl localhost` work | Variable | Default | Notes | |----------|---------|-------| -| `CORS__ALLOW_ORIGINS` | `[]` | Origins echoed in CORS response headers. | +| `CORS__ALLOW_ORIGINS` | `["http://localhost:3000","http://127.0.0.1:3000"]` | Origins echoed in CORS response headers. The dev defaults are inert in production: the `ALLOWED_ORIGINS` request guard above rejects a browser request carrying either origin **before** CORS applies. Inject real origins via env. | | `CORS__ALLOW_CREDENTIALS` | `false` | Off by design: this backend is unauthenticated and holds no cookies or sessions. | | `CORS__ALLOW_METHODS` | `["GET","POST","PUT","DELETE","OPTIONS"]` | | | `CORS__ALLOW_HEADERS` | `["*"]` | | @@ -57,8 +61,10 @@ spec forbids the combination and browsers reject it, so it can only ever be a fo | `STRINGDB_API__TIMEOUT` | `30` | Seconds. | | `STRINGDB_API__MAX_RETRIES` | `3` | Retries use exponential backoff. | | `STRINGDB_API__RETRY_DELAY` | `1.0` | Base backoff delay, seconds. | -| `STRINGDB_API__CALLER_IDENTITY` | `StringDB-Link/…` | Sent to STRING on every call, as STRING requests. | +| `STRINGDB_API__CALLER_IDENTITY` | `StringDB-Link/0.1.0` | Sent to STRING on every call, as STRING requests. | +| `STRINGDB_API__USER_AGENT` | `StringDB-Link/0.1.0` | `User-Agent` header on every STRING call. | | `STRINGDB_API__REDIRECT_BASE_URL` | `https://string-db.org` | The only STRING origin redirects are allowed to target. | +| `STRINGDB_API__ENDPOINTS` | *(map, see below)* | STRING paths keyed by operation: `resolve`, `network`, `interactions`, `enrichment`, `annotations`, `images`, `homology`, `homology_best`, `ppi_enrichment`, `enrichment_image`. Settable as a JSON object, but the defaults track the pinned STRING v12.0 API — overriding them is unsupported. | ## Caching @@ -97,13 +103,24 @@ This backend is **unauthenticated by design** — the `genefoundry-router` / rev edge auth at the trust boundary. It must never be published directly. See [`../SECURITY.md`](../SECURITY.md). +## Health check + +| Variable | Default | Notes | +|----------|---------|-------| +| `HEALTH_CHECK__ENABLED` | `true` | Serve `GET /health`. | +| `HEALTH_CHECK__INTERVAL` | `30` | Seconds; the interval the container healthcheck is expected to poll on. | +| `HEALTH_CHECK__TIMEOUT` | `10` | Seconds. | + ## Logging | Variable | Default | Notes | |----------|---------|-------| | `LOGGING__LEVEL` | `INFO` | `DEBUG` … `CRITICAL`. | -| `LOGGING__FORMAT` | `text` | `text` or `json`. | -| `LOGGING__FILE_ENABLED` | `false` | Plus `FILE_PATH`, `FILE_MAX_SIZE`, `FILE_BACKUP_COUNT`. | +| `LOGGING__FORMAT` | `json` | `json` or `text`. JSON is the default so logs are machine-parseable in a container. | +| `LOGGING__FILE_ENABLED` | `false` | Log to a file in addition to stdout. | +| `LOGGING__FILE_PATH` | `./logs/stringdb-link.log` | Only used when `FILE_ENABLED` is true. | +| `LOGGING__FILE_MAX_SIZE` | `10485760` | Bytes before rotation (10 MiB). | +| `LOGGING__FILE_BACKUP_COUNT` | `5` | Rotated files kept. | Logging is structured (structlog). A redaction processor (`stringdb_link/logging_config.py`, `redact_sensitive_processor`) strips sensitive values @@ -121,8 +138,8 @@ from every event, so secrets do not reach the logs; it is guarded by > `MCP__SERVER_NAME` is a **federation identity contract**, not cosmetics. The conformance > gate (`.github/workflows/conformance.yml`, `CONFORMANCE_NAME: stringdb-link`) and the > router's registry both assert `serverInfo.name == stringdb-link`. Overriding it breaks -> discovery behind the gateway. Note that `.env.example` currently ships a different value -> (`StringDB-Link Server`); the code default `stringdb-link` is the ratified one. +> discovery behind the gateway. The env templates therefore ship the ratified value, and +> `tests/unit/test_config_docs_contract.py` fails if one of them drifts away from it. `serverInfo.version` is single-sourced from the installed package metadata (`stringdb_link.__version__`), guarded by `tests/unit/test_version_single_source.py`. diff --git a/scripts/check_readme.py b/scripts/check_readme.py index 3a1709f..124c293 100644 --- a/scripts/check_readme.py +++ b/scripts/check_readme.py @@ -25,8 +25,12 @@ ROOT = Path(__file__).resolve().parent.parent README = ROOT / "README.md" +OWNER = "berntpopp" LINE_CEILING = 200 +# A badge is an image-link line: [![label](img)](href). Structural, not URL-substring based. +BADGE_LINE_RE = re.compile(r"^\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)$") + REQUIRED_SECTIONS = [ "Why", "Quick start", @@ -65,15 +69,33 @@ def is_router() -> bool: return not (ROOT / ".github/workflows/conformance.yml").exists() +def _workflow_badge(label: str, slug: str, workflow: str) -> str: + url = f"https://github.com/{OWNER}/{slug}/actions/workflows/{workflow}" + return f"[![{label}]({url}/badge.svg)]({url})" + + def expected_badges(slug: str) -> list[tuple[str, str]]: - """(label, substring that must appear in the badge line), in order.""" + """(label, the EXACT markdown line required), in order. + + Compared exactly, never by substring: a substring test would accept + ``https://evil.example/img.shields.io/badge/...`` and it is what CodeQL's + py/incomplete-url-substring-sanitization (correctly) rejects. The standard + fixes these four lines verbatim, so there is nothing to fuzzy-match. + """ gate = "security.yml" if is_router() else "conformance.yml" gate_label = "Security" if is_router() else "Conformance" return [ - ("Python 3.12+", "img.shields.io/badge/python-3.12"), - ("CI", f"/{slug}/actions/workflows/ci.yml/badge.svg"), - (gate_label, f"/{slug}/actions/workflows/{gate}/badge.svg"), - ("License: MIT", "img.shields.io/badge/license-MIT"), + ( + "Python 3.12+", + "[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-3776AB" + "?logo=python&logoColor=white)](https://www.python.org/)", + ), + ("CI", _workflow_badge("CI", slug, "ci.yml")), + (gate_label, _workflow_badge(gate_label, slug, gate)), + ( + "License: MIT", + "[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)", + ), ] @@ -95,19 +117,25 @@ def check_title(lines: list[str], errors: list[str]) -> None: def check_badges(text: str, lines: list[str], errors: list[str]) -> None: - badge_lines = [ln for ln in lines if "badge.svg" in ln or "img.shields.io" in ln] + badge_lines = [ln.strip() for ln in lines if BADGE_LINE_RE.match(ln.strip())] want = expected_badges(repo_slug()) + if len(badge_lines) != len(want): errors.append( f"expected exactly {len(want)} badges, found {len(badge_lines)}. " f"Canonical row: {', '.join(label for label, _ in want)}." ) return - for i, ((label, needle), got) in enumerate(zip(want, badge_lines), start=1): - if needle not in got: + + # strict=True is safe: the length check above already guarantees they match. + for i, ((label, expected), got) in enumerate(zip(want, badge_lines, strict=True), start=1): + if got != expected: errors.append( - f"badge {i} should be {label!r} (expected {needle!r} in the URL); got: {got.strip()[:80]}" + f"badge {i} ({label}) does not match the canonical line.\n" + f" expected: {expected}\n" + f" found: {got}" ) + if "?branch=" in text: errors.append("badge URLs must not pin ?branch= — they default to the default branch") @@ -150,8 +178,11 @@ def check_facts(text: str, errors: list[str]) -> None: def main() -> int: + # This file is vendored verbatim into all 22 fleet repos, whose ruff configs differ. + # Write to the streams directly rather than print() so it needs no per-file lint + # exemption anywhere (T201), and keep zip(strict=...) explicit (B905). if not README.exists(): - print("error: README.md not found", file=sys.stderr) + sys.stderr.write("error: README.md not found\n") return 1 text = README.read_text(encoding="utf-8") @@ -168,19 +199,13 @@ def main() -> int: if errors: klass = "router" if is_router() else "backend" - print( - f"README Standard v1 violations in {repo_slug()} ({klass}):\n", - file=sys.stderr, - ) + sys.stderr.write(f"README Standard v1 violations in {repo_slug()} ({klass}):\n\n") for err in errors: - print(f" - {err}", file=sys.stderr) - print( - "\nSee docs/README-STANDARD-v1.md in genefoundry-router.", - file=sys.stderr, - ) + sys.stderr.write(f" - {err}\n") + sys.stderr.write("\nSee docs/README-STANDARD-v1.md in genefoundry-router.\n") return 1 - print(f"README Standard v1: OK ({len(lines)} lines, ceiling {LINE_CEILING})") + sys.stdout.write(f"README Standard v1: OK ({len(lines)} lines, ceiling {LINE_CEILING})\n") return 0 diff --git a/tests/unit/test_config_docs_contract.py b/tests/unit/test_config_docs_contract.py new file mode 100644 index 0000000..2ff1ebb --- /dev/null +++ b/tests/unit/test_config_docs_contract.py @@ -0,0 +1,185 @@ +"""The configuration docs and env templates are a contract, not prose. + +README.md promises that ``docs/configuration.md`` documents *every* environment +variable, and ``docs/configuration.md`` states a default for each one. A +hand-typed table rots the moment a setting is added or a default changes, and a +README that lies is worse than no README — so these facts are pinned to the live +settings model here rather than trusted. + +The env templates are pinned too: ``extra="ignore"`` means a misspelled key in +``.env`` is silently dropped, and ``MCP__SERVER_NAME`` is the federation identity +the router's registry and the conformance gate both assert. + +Research use only; not clinical decision support.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel + +from stringdb_link.config import Settings + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONFIG_DOC = REPO_ROOT / "docs" / "configuration.md" +ENV_TEMPLATES = ( + REPO_ROOT / ".env.example", + REPO_ROOT / ".env.docker.example", + REPO_ROOT / ".env.npm.example", +) + +# Ratified by .github/workflows/conformance.yml (CONFORMANCE_NAME) and by the +# genefoundry-router registry, which carries no server_name override for us. +RATIFIED_SERVER_NAME = "stringdb-link" + +# The only setting whose default is a structured map. The doc names its keys +# instead of reproducing the JSON object, so its default cell is not compared — +# but the variable must still be documented like every other one. +STRUCTURED_DEFAULTS = frozenset({"STRINGDB_API__ENDPOINTS"}) + +_ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*") +_TABLE_ROW = re.compile(r"^\|(?P[^|]+)\|(?P[^|]*)\|") +_BACKTICKED = re.compile(r"`([^`]+)`") +_COMPOSE_INTERPOLATION = re.compile(r"\$\{([A-Z][A-Z0-9_]*)") + + +def _compose_interpolated_vars() -> set[str]: + """Names the docker/ compose files interpolate (e.g. ${PUBLIC_DOMAIN}). + + The container templates legitimately carry these alongside app settings: + they are consumed by compose, not by ``Settings``. + """ + names: set[str] = set() + for compose in (REPO_ROOT / "docker").glob("*.yml"): + names.update(_COMPOSE_INTERPOLATION.findall(compose.read_text(encoding="utf-8"))) + return names + + +def _render_default(value: Any) -> str: + """Render a settings default the way docs/configuration.md writes it.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, dict)): + return json.dumps(value, separators=(",", ":")) + return str(value) + + +def _live_settings(model: type[BaseModel], prefix: str = "") -> dict[str, str]: + """Env var name -> rendered default, walked from the live settings model.""" + resolved: dict[str, str] = {} + for name, field in model.model_fields.items(): + annotation = field.annotation + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + resolved.update(_live_settings(annotation, f"{prefix}{name.upper()}__")) + else: + default = field.get_default(call_default_factory=True) + resolved[f"{prefix}{name.upper()}"] = _render_default(default) + return resolved + + +def _documented_settings() -> dict[str, str]: + """Env var name -> documented default, parsed from configuration.md tables.""" + documented: dict[str, str] = {} + for line in CONFIG_DOC.read_text(encoding="utf-8").splitlines(): + row = _TABLE_ROW.match(line) + if row is None: + continue + names = [token for token in _BACKTICKED.findall(row["name"]) if _ENV_NAME.fullmatch(token)] + defaults = _BACKTICKED.findall(row["default"]) + for name in names: + documented[name] = defaults[0] if defaults else "" + return documented + + +def _env_template_keys(path: Path) -> dict[str, str]: + """KEY -> value for the assignments in an env template.""" + entries: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, _, value = stripped.partition("=") + entries[key.strip()] = value.strip() + return entries + + +def test_configuration_md_documents_every_setting() -> None: + """README's "every environment variable" claim is true, or CI fails.""" + live = set(_live_settings(Settings)) + documented = set(_documented_settings()) + + undocumented = live - documented + assert not undocumented, ( + "docs/configuration.md omits settable variables, so README's " + f"'every environment variable' claim is false: {sorted(undocumented)}" + ) + + invented = documented - live + assert not invented, ( + "docs/configuration.md documents variables the settings model does not " + f"read (extra='ignore' makes them silent no-ops): {sorted(invented)}" + ) + + +def test_documented_defaults_match_the_settings_model() -> None: + """Every documented default is the default the code actually sets.""" + live = _live_settings(Settings) + documented = _documented_settings() + + wrong = { + name: (documented[name], expected) + for name, expected in live.items() + if name not in STRUCTURED_DEFAULTS and documented.get(name) != expected + } + assert not wrong, f"documented default != code default (documented, actual): {wrong}" + + +def test_structured_default_is_still_documented() -> None: + """The one map-valued setting is named in the docs, even if not rendered.""" + documented = _documented_settings() + for name in STRUCTURED_DEFAULTS: + assert name in documented + + +@pytest.mark.parametrize("template", ENV_TEMPLATES, ids=lambda p: p.name) +def test_env_template_keys_are_real_settings(template: Path) -> None: + """A typo in a template is silently ignored at runtime — catch it here.""" + known = set(_live_settings(Settings)) | _compose_interpolated_vars() + unknown = set(_env_template_keys(template)) - known + assert not unknown, ( + f"{template.name} sets keys that neither the settings model reads " + f"(extra='ignore' drops them silently) nor a compose file interpolates: " + f"{sorted(unknown)}" + ) + + +@pytest.mark.parametrize("template", ENV_TEMPLATES, ids=lambda p: p.name) +def test_env_template_preserves_the_federation_identity(template: Path) -> None: + """Copying a template must not rename the server out of the fleet registry.""" + server_name = _env_template_keys(template).get("MCP__SERVER_NAME") + assert server_name in (None, RATIFIED_SERVER_NAME), ( + f"{template.name} would override serverInfo.name to {server_name!r}; " + f"the router registry and conformance gate require {RATIFIED_SERVER_NAME!r}" + ) + + +@pytest.mark.parametrize("template", ENV_TEMPLATES, ids=lambda p: p.name) +def test_env_template_keeps_cors_credentials_off(template: Path) -> None: + """The backend is unauthenticated: credentialed CORS is never correct.""" + credentials = _env_template_keys(template).get("CORS__ALLOW_CREDENTIALS", "false") + assert credentials.lower() == "false", ( + f"{template.name} turns on credentialed CORS for an unauthenticated backend" + ) + + +def test_nested_list_variable_is_parsed_as_a_json_array( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The documented `SECTION__FIELD` + JSON-array env contract really holds.""" + monkeypatch.setenv("CORS__ALLOW_ORIGINS", '["https://example.org"]') + settings = Settings(_env_file=None) + assert settings.cors.allow_origins == ["https://example.org"]