Skip to content

feat(mcp): GeneFoundry contract-hardening sweep (behaviour gate CONFORMANT)#34

Merged
berntpopp merged 3 commits into
mainfrom
feat/mcp-contract-hardening-v1
Jul 15, 2026
Merged

feat(mcp): GeneFoundry contract-hardening sweep (behaviour gate CONFORMANT)#34
berntpopp merged 3 commits into
mainfrom
feat/mcp-contract-hardening-v1

Conversation

@berntpopp

Copy link
Copy Markdown
Owner

GeneFoundry fleet MCP contract-hardening sweep

Brings stringdb-link into compliance with the GeneFoundry MCP contract standards and closes every defect the vendored behaviour gate — and the live MCP audit (#33) — surfaced.

Standards adopted:

  • Response-Envelope Standard v1 / v1.1aisError on every error frame, the closed error_code enum, actionable errors.
  • Tool-Schema Documentation Standard v1 (genefoundry-router#75) — examples on every required + array param; closed vocabularies declared as enums matching the runtime.
  • Tool-Surface Budget Standard v1 (genefoundry-router#73) — outputSchema suppressed, dereference_schemas=False.
  • Behaviour gate (genefoundry-router#76) vendored byte-identical from genefoundry-router@791363c and wired into mcp-conformance CI.

The enum rule followed here is schema ⊆ runtime: never advertise a value the runtime rejects (verified live — e.g. output_format narrowed to the single value STRING actually serves).

Closes the contract half of #33.

Behaviour gate — before / after

Both run against a locally-running server (docs/conformance/behaviour.py):

BEFORE:  NON-CONFORMANT: stringdb-link (21 pass, 20 fail, 10 UNGATED, 0 inconclusive)
AFTER:   CONFORMANT:     stringdb-link (48 pass,  0 fail,  0 UNGATED, 3 inconclusive)

The 20 failures were isError:false on every error envelope + a non-actionable "The request was rejected as invalid." on every tool. The 10 UNGATED were every tool's required params lacking examples (which also blocked the dynamic filter probes). The 3 remaining "inconclusive" are single-item tools (get_network_link, compute_ppi_enrichment, get_network_image) that return no collection to filter-probe — honest, not a defect.

Surface survey — before / after

                       ver     tools  surface  out%  doc%  ex
BEFORE  stringdb-link  4.0.6      10   4,625t   24%  100%   0
AFTER   stringdb-link  4.1.0      10   4,083t    0%  100%  26

Live repros (local server, tools/call)

isError + canonical error_code + actionable field (all error frames):

get_network_link output_format=svg   -> isError=True  error_code=invalid_input  field=output_format
compute_functional_enrichment (bad background) -> isError=True error_code=invalid_input field=background_string_identifiers retryable=False

Defect 1 — get_network_image (was internal_error / empty in every format):

get_network_image {TP53,MDM2 @9606} -> success  result={image_format:image, content_type:image/png, image_size_bytes:9772, image_base64:"iVBORw0KGgo…"}

Defect 2 — get_functional_annotations (was not_found "tool is not available"):

get_functional_annotations {CFTR @9606} -> success  total_count=885  (description fenced as untrusted_text)

Defect 3 — compute_functional_enrichment pagination (was ~992 terms / ~421k tokens, no knob):

compute_functional_enrichment {30-gene epilepsy panel, limit:5} -> success  results=5  total_count=478  truncated=True

Defect 4 — get_network_link output_format (was 8/9 enum values → success:true, result:{}):

output_format=json -> success  result={url:"https://version-12-0.string-db.org/cgi/link?to=…"}
output_format=svg  -> isError=True  error_code=invalid_input  field=output_format

Defect 5 — enrichment background_error (was false upstream_unavailable, retryable:true):

bad background -> isError=True  error_code=invalid_input  field=background_string_identifiers  retryable=False

Homology camelCase lead — verified NOT a live defect: get_protein_homology_scores {CDK1,CDK2} returns populated string_id_a/bitscore rows. required_score scaling lead — not a silent-empty: the schema declares a 0.0–1.0 confidence (bounded ge/le, description "Minimum confidence score (0.0-1.0)"), and the service scales it to STRING's 0–1000 internally (round(required_score * 1000)). A caller passing 1000 is rejected by the le=1.0 bound rather than silently matching nothing.

Notes

  • Version 4.0.6 → 4.1.0 (MINOR: schemas gain fields; outputSchema drops an optional field). CHANGELOG updated under ## [4.1.0].
  • make ci-local green (452 passed, 2 skipped).
  • Untrusted-text fencing is preserved on the wire (Response-Envelope v1.1a); suppressing outputSchema does not weaken it.

🤖 Generated with Claude Code

https://claude.ai/code/session_019BYK7j7jsPXmBspMsDtfhH

berntpopp and others added 3 commits July 15, 2026 10:36
…enum

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 (only `raise` drops it);
route every error through ToolResult(..., is_error=True).

Also close error_code to the six-value canonical enum: internal_error ->
internal (the behaviour gate rejects anything outside the closed set). And make
validation errors actionable — build_error_envelope now emits 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.

Behaviour gate: 20 fail -> 0 fail (isError + actionable-error classes cleared).
Vendor the byte-identical behaviour gate from genefoundry-router
feat/mcp-contract-hardening-v1 (docs/conformance/behaviour.py @ 791363c).

Refs genefoundry-router#73 #75 #76; refs #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BYK7j7jsPXmBspMsDtfhH
…ct defects

Complete the GeneFoundry contract-hardening sweep on top of the isError/error_code
commit. The vendored behaviour gate goes from 20 fail / 10 UNGATED to CONFORMANT
(0 fail / 0 UNGATED); surface 4,625t -> 4,083t (outputSchema 24% -> 0%).

Schema documentation (Tool-Schema Documentation Standard v1):
- Every required and array parameter carries `examples` (json_schema_extra{"example"}
  -> `examples=[...]`, array form for arrays) — this is what unGATES every tool.
- Closed vocabularies are declared as enums matching the runtime.

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

Audited contract defects (issue #33, each reproduced live):
- get_network_link: `output_format` narrowed to the one value the runtime serves
  (`json`); the other 8 enum values returned success+empty (silent-empty filter).
- compute_functional_enrichment/compute_ppi_enrichment: a bad custom background is
  now invalid_input naming `background_string_identifiers`, not a false retryable
  upstream_unavailable (STRING returns 200 + [{"error":"background_error"}]).
- get_functional_annotations: stop sending allow_pubmed/only_pubmed unless opted in
  (their presence ballooned the response past the byte cap; the tool always failed).
- get_network_image: return a base64 NetworkImageResult instead of a binary body
  that defeated the JSON envelope (empty result / internal error).
- get_interaction_partners / compute_functional_enrichment: honest, limit-invariant
  total_count + truncated; enrichment gains `limit` and `category` (a 30-gene panel
  returned ~992 terms / ~421k tokens with no knob to shrink it).

Wire the behaviour probe into the mcp-conformance workflow. Version 4.0.6 -> 4.1.0
(schemas gain fields; outputSchema drops an optional field; no wire break beyond the
error_code rename). make ci-local green (452 passed).

Refs genefoundry-router#73 #75 #76; refs #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BYK7j7jsPXmBspMsDtfhH
…t isError, keep link formats, MCP-only image base64, general in-band error

Six fixes from the PR #34 review (Codex gpt-5.6-sol):

1. get_interaction_partners: paginate PER PROTEIN. The prior global head-slice
   (all_partners[:limit]) returned only the first query protein's partners and
   omitted every later protein; the hardcoded 500 fetch could not prove a true
   total. Now fetch the full partner set (limit omitted upstream), apply `limit`
   per query protein (grouped by string_id_a), report the true limit-invariant
   total, and set `truncated`.
2. Returned-dict isError: a tool that RETURNS {"success": false} was wrapped as a
   success with isError:false. The chokepoint (finalize_tool_result) now normalizes
   a returned error frame to isError:true, closing error_code to the enum.
3. get_network_link output_format: restore the four formats STRING actually serves
   (json/tsv/tsv-no-header/xml) instead of narrowing to json (capability loss →
   422). tsv/xml are shaped into a structured result: `url` extracted + STRING's raw
   text in `formatted`. The empty result:{} was a shaping bug, not proof of non-support.
4. Image REST contract: POST /api/images/network keeps its raw-binary response
   (operation download_network_image, excluded from MCP). The base64 shaping moved to
   a dedicated MCP-only route POST /api/images/network/json (operation get_network_image).
5. Empty image body: an empty upstream body is now upstream_unavailable, never a
   success with 0 bytes / empty base64 (a new silent-empty).
6. STRING in-band errors: handle the GENERAL class — ANY {"error": X} at HTTP 200 →
   invalid_input naming a field (background_error → background_string_identifiers,
   otherwise identifiers), never a field-less error or false retryable upstream outage.

Behaviour gate remains CONFORMANT (51 pass, 0 fail, 0 UNGATED). make ci-local green
(463 passed). New regression tests in tests/unit/test_contract_rework_v1.py and the
empty-image facade test.

Refs genefoundry-router#73 #75 #76; refs #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BYK7j7jsPXmBspMsDtfhH
@berntpopp
berntpopp merged commit ec4abf9 into main Jul 15, 2026
7 checks passed
@berntpopp
berntpopp deleted the feat/mcp-contract-hardening-v1 branch July 15, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant