Fix #2639: orchestrator MCP contract tests + array/object schema fix - #2671
Conversation
Closes #2639 (contract-surface portion). * Fix `_json_type_to_python` (orchestrator/mcp_server.py): missing `array`/`object` rows silently mapped to `str`, so every dict-valued (`config`) and list-valued (`roles`) MCP parameter was unreachable — FastMCP's Pydantic layer rejected valid input with a misleading "should be a valid string" error before the handler ran. * Add integration tests (integration_tests/test_orchestrator_mcp_contract.py) driving the live MCP server over streamable-HTTP: tool discovery, argument validation across submit_task/run_agent_task/babysit_pr, route-level reviewer-only / cross-phase reason propagation, validate_config, get_status, idempotency (sequential + concurrent duplicate create), and the unauthenticated-by-design contract. * Add orchestrator MCP URL fixture (integration_tests/conftest.py) keyed off the local-overlay hostPort mapping with a clear skip when unreachable. * Add unit tests (orchestrator/tests/test_mcp_server.py) locking in the type-map fix, the `wait` shim's edge cases, and the RateLimiter contention bound. Follow-ups filed for unfixed gaps surfaced by the audit: #2665 (Pydantic-vs-handler error envelope), #2667 (MCP Service port production reachability), #2668 (round-trip tests blocked on pod- level LLM-response injection), #2669 (RateLimiter thread-safety).
This comment has been minimized.
This comment has been minimized.
FastMCP runs with stateless_http=True and dispatches each tool call through anyio.to_thread.run_sync, so RateLimiter.allow() can be hit from multiple OS worker threads. The previous "single-event-loop, no locks needed" claim held only because the limiter was called from the async wrapper before to_thread.run_sync — one refactor away from a silent prune-and-len race. Wraps _requests mutation in a threading.Lock and tightens the threaded-burst test to assert exact bound (was a bounded-overshoot assertion modeling the lockless worst case). Drops the matching "unverified invariant" caveat in the integration-test docstring.
…sitPRValidation, TestSubmitTaskIdempotency
- test_reviewer_only_roster_surfaces_reason / test_cross_phase_role_surfaces_reason:
use _TEST_REPO (test-owner/test-repo) instead of owner/repo; the CUSTOM
mode repo allowlist check returns repo_not_allowed before reaching role
validation when the repo is unknown.
- test_missing_pr_number_rejected: remove pr_number from babysit_pr required[];
FastMCP's Pydantic layer was catching the missing field before the handler ran,
returning _raw error text instead of the documented {"error":"..."} envelope.
With pr_number optional (default None), the handler receives None and returns
the structured error.
- test_duplicate_create_returns_existing_pipeline_metadata /
test_concurrent_duplicate_create_serializes: increase _make_request timeout
to 120s for all three pipeline-creation handlers (submit_task, run_agent_task,
babysit_pr). The create_pipeline route calls ls_remote_branch via the gateway,
which can block up to 30s while a git ls-remote network call times out. With
the prior 30s client timeout, both timeouts raced and the MCP tool always
returned {"error": "timed out"}.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- test_reviewer_only_roster_surfaces_reason / test_cross_phase_role_surfaces_reason:
Seed repositories.yaml in CI with test-owner/test-repo in writable_repos so
the orchestrator's CUSTOM-mode allowlist check passes and role validation is
reached. Both tests use _TEST_REPO which must be allowlisted to get past
repo_not_allowed.
- test_missing_pr_number_rejected: Use Optional[T] annotation for non-required
fields in _make_tool_fn so Pydantic v2 correctly treats them as nullable.
bare int = None causes Pydantic to raise "Field required" when the argument
is omitted; Optional[int] = None allows None and passes it to the handler
which returns the structured {"error": "pr_number must be a positive integer"}.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- test_tool_schema_required_fields: update to expect ['repo'] only.
The schema deliberately omits pr_number from required so the handler
can return a structured {'error': 'pr_number must be a positive
integer'} response when omitted; that matches the integration test
test_missing_pr_number_rejected.
- test_created_not_started_when_start_fails: extend the mock side-effect
signature with timeout=None. _handle_run_agent_task now calls
_make_request with timeout=120 for the create POST, which made the
test's _side(path, method, data) raise TypeError and caused
handle_tool_call to return {'error': ...} instead of the expected
task_id payload.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…aded - orchestrator/mcp_tools.py: restore "pr_number" in babysit_pr schema's required list. The handler at _handle_babysit_pr validates pr_number as a positive integer, so removing it from required was inconsistent with the handler and broke test_tool_schema_has_required_fields. - integration_tests/test_orchestrator_mcp_contract.py: add a _healthy_gateway_or_skip fixture and apply it to the four tests that require the orchestrator's gateway-readiness gate to pass. In CI the gateway runs with dummy GitHub credentials and reports status=degraded indefinitely, so the route returns reason=gateway_not_ready before reaching the contract surface these tests cover. Locally with a real GH token the gateway is healthy and the tests still run.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…quired design The previous autofix (f155dac) added pr_number back to the babysit_pr inputSchema 'required' list to satisfy test_tool_schema_has_required_fields in test_skill.py, but that broke the new unit test test_tool_schema_required_fields and the integration test test_missing_pr_number_rejected — both of which encode this PR's design intent that pr_number is intentionally optional at the schema layer so the handler can return a structured {'error': 'pr_number must be a positive integer'} envelope rather than letting Pydantic raise a generic 'Field required'. - orchestrator/mcp_tools.py: restore 'required': ['repo'] (drop pr_number). - integration_tests/test_babysit_pr/test_skill.py: update the pre-existing test_tool_schema_has_required_fields to match the design — pr_number is in 'properties' but not 'required'.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns. This is plumbing — MCP schema mapping bug fix, RateLimiter lock, request-timeout bump, and tests. The structured {"error": ..., "reason": ..., "existing_pipeline_id": ...} envelopes are consumed by MCP clients programmatically (the correct shape for an agent-facing tool surface), not rendered to humans, so they don't trip the "structured output for humans" anti-pattern. Dropping pr_number from required to land structured handler errors instead of a raw Pydantic Field required likewise improves the contract for programmatic callers.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Approving — the two real fixes (_json_type_to_python array/object rows; RateLimiter lock under anyio.to_thread) are correct and well-tested, and the new integration suite is the right shape to catch the schema-coercion class of regression that lay latent here. A handful of non-blocking concerns below.
What this PR gets right
-
mcp_server.py:252-260— adding thearray → listandobject → dictrows fixes a real bug. Theconfig/rolesparameters on every tool advertising those JSON-Schema types were silently coerced tostrby the fallback, so the Pydantic layer rejected any structured input before the handler ran. The parametrized unit test intest_mcp_server.py:65-79locks in the full mapping plus the intentional fall-through. -
mcp_server.py:80-100— addingthreading.LocktoRateLimiteris the right fix forstateless_http=True+anyio.to_thread.run_sync. The prune-len-check-append sequence is now atomic, and the exact-bound test (test_threaded_burst_is_exact) would reliably fail on regression because the prior code's prune-and-len race lets every contesting thread observe the same pruned state before any append lands. -
mcp_server.py:194-202— theis_optional ⇒ annotation | Nonewidening correctly resolves the Pydantic v2 "Field required" mismatch for fields that havedefault=Nonebut a bare-type annotation. The comment captures the rationale well.
Non-blocking
1. babysit_pr schema accuracy regression
mcp_tools.py:274 removes pr_number from required so the handler's structured {"error": "pr_number must be a positive integer"} envelope can fire instead of Pydantic's "Field required". I understand the motivation (and #2665 tracks the proper fix), but right now:
- Clients introspecting via
list_toolsseepr_numberas optional in the discovered schema. - The parameter description (
mcp_tools.py:248-250) still reads "GitHub PR number to babysit (must be open, non-fork, non-empty)" — no hint that omission is an error. - The schema-vs-behaviour contract is now self-contradicting at the wire.
At minimum, update the pr_number description to note that it is effectively required ("Required. GitHub PR number..."). Better still, tackle this at the FastMCP error-envelope layer so the schema can be honest. The current state lets a Claude Code completion suggest omitting pr_number and only learn the truth at call time.
2. Annotation-widening side-effect on fields with non-None defaults
mcp_server.py:198-202 appends | None to every optional field, including ones that have an explicit non-None default in the JSON schema. For list_tasks.status_filter (default "active"):
- Old wire contract: client cannot send
null; Pydantic rejects it; omitting the field falls back to"active". - New wire contract: client can send
null; Pydantic accepts; the handler atmcp_tools.py:2040readsargs.get("status_filter", "active"), which returnsNone(because the key is present), and then no filter branch matches — the call silently returns an empty list.
Same pattern applies to list_tasks.limit (default 10), get_status.wait (default 0), cancel_task.cleanup (default False), etc. None of these are catastrophic, but they're behaviourally surprising. Two options:
- Tighten the rule: only widen to
| Nonewhen the default isNone(i.e. when we ourselves set it on line 196). Fields with their own explicit non-None default should keep the bare annotation. - Or, change the handlers to coerce
Noneback to the schema default (e.g.args.get("status_filter") or "active"). I'd lean toward the first — keeps the wire contract minimal.
3. _make_request bumped to 120s exceeds the MCP client deadline
mcp_tools.py:1337, 1491, 1607 raise the create-pipeline POST timeout from 30s to 120s "so this call survives the worst-case gateway I/O path." Two observations:
- The streamable-HTTP MCP transport deadline on the Claude Code side is ~30s (the same constraint that drives
GET_STATUS_MAX_WAIT = 25inmcp_server.py:42). So if_handle_submit_taskactually consumes 60-120s, the client has already given up — but the orchestrator has likely created the pipeline. The user retries and gets a 409 (which the handler does decode intoexisting_pipeline_id— good), but the UX is a confusing "timeout → already exists". - The PR comment ties this to the gateway's 30s
ls_remote_branchtimeout; if that's the worst case, 60s would have plenty of margin without exceeding the client deadline. 120s reads more as "round up to a comfortable number" than as "we measured this".
Either pick a value below 30s and let the gateway timeout surface as the bounded failure mode, or keep 120s and accept the timeout/409 dance — but document it on the tool description so the caller knows to treat client-side timeout as "check existing_pipeline_id and assume the pipeline was created".
4. CI gateway-readiness gate skips most of the new contract coverage
_healthy_gateway_or_skip (integration_tests/test_orchestrator_mcp_contract.py:209-241) is session-scoped and skips when gateway is degraded, which is the documented CI default (dummy credentials → degraded forever). That means the four route-touching tests — test_reviewer_only_roster_surfaces_reason, test_cross_phase_role_surfaces_reason, test_duplicate_create_returns_existing_pipeline_metadata, test_concurrent_duplicate_create_serializes — never actually run in CI on this PR's stack. The argument-validation tests cover the handler short-circuit, which is most of the point, but the headline "MCP boundary preserves details.reason codes" claim doesn't get a green tick from CI alone.
This isn't a blocker (it's exactly what the fixture docstring says), but please be explicit in the PR description that the four reason-propagation/idempotency tests pass only in a local-overlay run with a real CLAUDE_CODE_OAUTH_TOKEN. Right now the test plan reads like all 19 run in CI.
5. Schema-shape brittleness in test assertions
test_mcp_tools.py:2503 asserts schema["required"] == ["repo"] (exact list, exact order). A future PR that adds a second required field (e.g. pipeline_id) would have to update this assertion, and the exact ordering pins us to insertion order in required. A set comparison (set(schema["required"]) == {"repo"}) — or assert "repo" in required and "pr_number" not in required — is more robust and reads closer to intent. Same shape in test_babysit_pr/test_skill.py:261-264.
6. Minor: test_orchestrator_mcp_contract.py defensive guards that aren't reachable from the wire
_apply_get_status_wait's defensive branches for None, [5], "5" are covered in the unit test (test_zero_negative_or_non_numeric_no_op) — but over the actual MCP transport, the Pydantic layer rejects [5] and coerces "5" to 5.0 before the handler runs. The unit test is still valuable as a contract for the function, just worth noting that "client sends wait: [5]" can't actually reach this branch.
Things I checked and was happy with
- The
RateLimiter.allow()lock placement (now = time.time()outside lock is fine — per-thread local read, no cross-thread comparison). - The
monkeypatch.setattr("mcp_server._async_sleep", sleeper)pattern. The function looks up_async_sleepvia the module namespace at call time, so the patch reaches it. - The repositories.yaml YAML structure in
.github/workflows/test-integration.yml:130— indentation and keys matchconfig/repositories.yaml.exampleand gateway expectations. _TEST_REPO = "test-owner/test-repo"is the same repo seeded inintegration_tests/conftest.py:_write_test_config, so the allowlist gate is satisfied for the route-level tests.- The concurrent-duplicate-create test relies on state-store atomic create-or-fail; that's the actual contract, so the test exercises real serialization rather than a mock.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- mcp_tools.py: pr_number description now states "Required" up-front and links to #2665 for why the schema's required list intentionally omits it. - mcp_server.py: extract _build_tool_signature and tighten the | None widening rule. We now only widen when *we* synthesize the None default; fields with their own non-None schema default (e.g. status_filter defaulting to "active") keep the bare annotation so Pydantic rejects null at the wire instead of the handler silently bypassing the schema default via args.get(name, default). - mcp_tools.py: drop the create_pipeline POST timeout from 120s to 25s on submit_task / run_agent_task / babysit_pr so the call stays inside the MCP client's ~30s streamable-HTTP deadline. The previous 120s let the client time out first and produced a confusing timeout-then-409 dance. - tests: assert schema["required"] membership instead of exact-list/order; add a TestBuildToolSignature suite that pins the widening contract.
|
Thanks for the careful read. Disposition per non-blocking item: 1. 2. Annotation-widening side-effect on fields with non-None defaults — 3. 4. CI gateway-readiness gate skips most of the new contract coverage —
If you want this surfaced in the PR description itself, please copy the block above into the body or grant the sandbox edit permission on this PR and I'll do it directly. 5. Schema-shape brittleness in test assertions — 6. Defensive — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review at a287593. The delta since my last pass (16957e76) is feedback-response only, and the changes strengthen the agent-facing MCP surface rather than introducing any anti-patterns:
-
mcp_tools.py:248-256— thepr_numberdescription now opens with "Required." and explains the schema/handler split inline (links #2665). This resolves the schema-vs-description contradiction I flagged: an LLM caller introspectinglist_toolssees the truth in the parameter doc before it sees the schema'srequiredlist. -
mcp_server.py:_build_tool_signature— tightened so the| Nonewidening fires only when we synthesizedefault=None. Fields with their own non-None schema default (status_filter="active",limit=10,wait=0,cleanup=False) keep the bare annotation, so Pydantic rejects an inboundnullat the wire instead of lettingargs.get(name, default)silently returnNonepast the handler's default-branch. The newTestBuildToolSignaturesuite (test_mcp_server.py:88-185) pins all three rules — required / optional-with-schema-default / optional-no-default — which is the right shape for an agent-facing contract that needs to stay honest as the tool set grows. -
mcp_tools.py:1341, 1498, 1616— POST timeout dropped from 120s to 25s onsubmit_task/run_agent_task/babysit_pr, with comments cross-referencingGET_STATUS_MAX_WAIT. The MCP caller now always sees a definite response or our own structured timeout inside its ~30s streamable-HTTP budget, instead of the client giving up first and the agent retrying into a 409. This is a real agent-UX improvement. -
Test assertions — set-membership over exact-list ordering in both
test_mcp_tools.py:2506-2510andtest_skill.py:261-267, which makes the property-under-test (pr_numberis intentionally optional in the schema) explicit and robust to future required-field additions.
No agent-mode design concerns.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewing changes since 16957e76. The single new commit a287593 addresses the four actionable items from my prior review thoroughly; the two disagreements (#4 PR-body edit gated by gateway, #6 function-level defensive tests) are well-justified.
Verified fixes
1. pr_number schema-vs-behaviour contract is now self-documenting (mcp_tools.py:247-256)
Description now leads with "Required." and explains why the field is intentionally omitted from required[] (with a link to #2665). A client introspecting the tool schema sees the truth at the description level even though the JSON-Schema required list says otherwise. Good.
2. Annotation-widening tightened correctly (mcp_server.py:239-283)
The new _build_tool_signature helper widens to T | None only when it synthesizes a default=None itself. Fields with a non-None schema default (status_filter="active", limit=10, wait=0, cleanup=False) keep their bare annotation, so the wire contract now correctly rejects null at Pydantic before args.get(name, default) can silently bypass the schema default. I traced the four affected handlers:
list_tasksreadsargs.get("status_filter", "active")atmcp_tools.py:2050— barestrannotation now blocksnull.list_tasks.limit(default 10),get_status.wait(default 0),cancel_task.cleanup(default False) — same pattern, all now wire-rejected fornull.
The three-rule contract is explicitly pinned by the new TestBuildToolSignature suite (5 test methods + a parametrized table over int/bool/number). Refactor-safe.
3. Timeout reduced to 25s (mcp_tools.py:1346, 1501, 1617)
All three create-pipeline POSTs now cap at 25s, comfortably under the MCP client's ~30s streamable-HTTP deadline (GET_STATUS_MAX_WAIT = 25 in mcp_server.py:43). Traced the failure path: timeout → socket.timeout → caught by handle_tool_call's except Exception at mcp_tools.py:1238 → {"error": "timed out"}. On retry, the 409 path at mcp_tools.py:1356-1368 returns existing_pipeline_id with reason propagated. Inline comments cross-reference GET_STATUS_MAX_WAIT so future readers can find the constraint. Good.
4. Schema-shape assertions use membership (test_mcp_tools.py:2508-2513, test_skill.py:266-267)
Both tests now assert "repo" in required and "pr_number" not in required against the set, decoupling the test from required[] ordering and future-additions. The positive companion assertion locks in shape without overfitting. Good.
Things I checked and was happy with
_build_tool_signatureextraction is a pure refactor of the inline closure code; the per-property iteration order is preserved (Python 3.7+ dict insertion order).inspectimport moved to module top; no lingering innerimport inspectleft dangling.- No schemas in
PIPELINE_TOOLSdeclare an explicit"default": null(would be an edge case where the tightening wouldn't widen but Pydantic would still raise "Field required"). Confirmed by grepping all 18"default":entries. - The new
_apply_get_status_waitunit tests forwait: None/wait: [5]/wait: "5"are now correctly framed as a function-level contract (unreachable from the wire after the tightening), as the author notes in their reply to item #6. cleanuphandler atmcp_tools.py:2103usesargs.get("cleanup")(truthy check), so even the impossible-via-wireNonewould still do the right thing — defense in depth survives the rule tightening.
Acknowledged disagreement (still reasonable)
#4 — PR body cannot be edited from this sandbox because the gateway only allows gh pr edit for egg-owned PRs. The author surfaced the test-plan caveat (only 15 of 19 integration tests run in CI without a real GH token) in a PR comment as a workaround. Acceptable.
#6 — Leaving the defensive _apply_get_status_wait None/"5"/[5] tests in as a function contract is the right call; the cost is negligible and the tests would catch a future refactor that exposes the function to a different caller. Fine.
No new issues introduced. The schema-coercion fix (_json_type_to_python array/object rows) and the RateLimiter.allow() lock remain correct from the prior review. Approving.
— Authored by egg
|
egg review completed. View run logs 26 previous review(s) hidden. |
Closes #2639, #2669. (Contract surface + RateLimiter thread-safety. Round-trip starting points 1-3 are deferred to #2668 — see "Coverage not attempted" below.)
Summary
orchestrator/mcp_server.py):_json_type_to_pythonhad no rows forarrayorobject, silently falling through tostr. Every dict-valued (config) or list-valued (roles) MCP parameter was unreachable — FastMCP's Pydantic layer rejected valid input with"Input should be a valid string"before the handler ran. Adds the missing rows + a unit test that locks in the full mapping.orchestrator/mcp_server.py, closes RateLimiter.allow() thread-safety claim is unverified under stateless-HTTP #2669):RateLimiter.allow()was documented as lockless-safe under the "single event loop" assumption, but FastMCP'sstateless_http=Truemode dispatches tool calls throughanyio.to_thread.run_sync— so the limiter can be hit from multiple OS worker threads. Wraps_requestsmutation in athreading.Lockand tightens the threaded-burst test to assert exact bound (was bounded-overshoot).integration_tests/test_orchestrator_mcp_contract.py, 19 tests): drive the live MCP server over streamable-HTTP. Tool discovery, argument validation forsubmit_task/run_agent_task/babysit_pr, route-levelreviewer_only_roster/cross_phase_rolereason propagation,validate_config,get_statuson unknown task_id, idempotency (sequential and concurrent duplicate create), and the unauthenticated-by-design auth boundary.integration_tests/conftest.py): discovershttp://localhost:9850/mcpvia the local-overlay hostPort, with a clear skip when unreachable.orchestrator/tests/test_mcp_server.py, 24 tests):_json_type_to_pythonmapping,_apply_get_status_waitedge cases (bool / zero / negative / cap), and a threaded burst againstRateLimiterthat pins the new exact-bound invariant.Coverage not attempted (tracked separately)
Test plan
make test-integrationgreen on a clean k3s deploy that picks up the rebuilt image (the running pod doesn't have the_json_type_to_pythonfix, so 4-5 of the new integration tests will fail until the image is rebuilt and reapplied).make test-allgreen for the unit tests (orchestrator/tests/test_mcp_server.py, 24 tests).Test / aggregaterequired check passes — the integration job in.github/workflows/test-integration.ymlrunsmake test-integrationagainst a freshly-built k3s deployment, which is the only way to verify the fix end-to-end.Notes
I did NOT redeploy or alter the k3s stack from my sandbox (no permission). The fix is verified by the unit test; CI builds and deploys a fresh image on this PR, which is where the integration tests will exercise the fix on a live server.