diff --git a/agents/gewu b/agents/gewu index 65bc8a9..5a6cd7a 160000 --- a/agents/gewu +++ b/agents/gewu @@ -1 +1 @@ -Subproject commit 65bc8a94228b8a55c6cfe9a15b0717f4d7f6fbda +Subproject commit 5a6cd7af4f5a62c3c0af49f2f4696a5ae72f4d51 diff --git a/src/gewu/transports/mcp_server.py b/src/gewu/transports/mcp_server.py index a07dd19..f632e6b 100644 --- a/src/gewu/transports/mcp_server.py +++ b/src/gewu/transports/mcp_server.py @@ -1285,10 +1285,10 @@ def create_mcp_server( @server.tool(name="proposal_submit", structured_output=False) def proposal_submit(proposal_json: str, ctx: Context) -> str: - """Validate and submit one bounded typed proposal revision.""" + """Validate and submit one bounded proposal; report any safe input repair.""" principal = _principal(ctx, identity_provider) - proposal = _parse_proposal(proposal_json) + proposal, input_repaired = _parse_proposal(proposal_json) context = context_provider() _authorize_proposal(principal, proposal, context) staged_extractions: dict[str, ExtractionRecord] = {} @@ -1321,6 +1321,7 @@ def create_mcp_server( "status": "accepted" if outcome.receipt else outcome.validation.status, "validation_status": outcome.validation.status, "eligible_for_auto_accept": outcome.validation.eligible_for_auto_accept, + "input_repaired": input_repaired, "findings": [ finding.model_dump(mode="json") for finding in outcome.validation.findings ], @@ -1339,8 +1340,20 @@ def create_mcp_server( raise ValueError("Proposal exceeds the supported request size") try: draft = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys) - except (ValueError, TypeError) as error: - raise ValueError("Proposal draft must be valid JSON") from error + except (ValueError, TypeError): + return json.dumps( + { + "status": "invalid", + "issues": [ + { + "path": "", + "code": "invalid_proposal_draft", + "message": "Proposal draft must be valid JSON", + } + ], + }, + ensure_ascii=False, + ) if not isinstance(draft, dict): raise ValueError("Proposal draft must be a JSON object") try: @@ -1378,10 +1391,36 @@ def create_mcp_server( @server.tool(name="proposal_evaluate", structured_output=False) def proposal_evaluate(proposal_json: str, ctx: Context) -> str: - """Evaluate one bounded proposal without storing or publishing it.""" + """Evaluate one bounded proposal without storing or publishing it. + + A single missing final outer brace is repaired only if the result + is valid JSON and matches the full contract. The response exposes + input_repaired=true. Other malformed JSON returns status=invalid. + A valid proposal may instead return needs-review when an applicable + policy requires an owner decision. + """ principal = _principal(ctx, identity_provider) - proposal = _parse_proposal(proposal_json) + try: + proposal, input_repaired = _parse_proposal(proposal_json) + except ValueError: + return json.dumps( + { + "proposal_id": None, + "revision": None, + "status": "invalid", + "eligible_for_auto_accept": False, + "findings": [ + { + "code": "PROPOSAL_JSON_INVALID", + "message": "Proposal does not match the GEWU v1 contract", + "path": "proposal_json", + } + ], + "affected_policy_ids": [], + }, + ensure_ascii=False, + ) context = context_provider() _authorize_proposal(principal, proposal, context) staged_extractions: dict[str, ExtractionRecord] = {} @@ -1408,6 +1447,7 @@ def create_mcp_server( "revision": proposal.revision, "status": report.status, "eligible_for_auto_accept": report.eligible_for_auto_accept, + "input_repaired": input_repaired, "findings": [finding.model_dump(mode="json") for finding in report.findings], "affected_policy_ids": report.affected_policy_ids, }, @@ -1673,17 +1713,32 @@ def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object] return result -def _parse_proposal(proposal_json: str) -> ProposalRevision: +def _parse_proposal(proposal_json: str) -> tuple[ProposalRevision, bool]: if len(proposal_json.encode("utf-8")) > 1_000_000: raise ValueError("Proposal exceeds the supported request size") try: parsed = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys) proposal = ProposalRevision.model_validate_json(proposal_json, strict=True) + except json.JSONDecodeError as error: + # Tool-call argument serialization can drop the final outer brace while + # copying a prepared JSON string. Accept only this unambiguous one-byte + # truncation; nested truncation, duplicate keys and schema errors remain + # invalid. The response exposes the repair so the caller can report it. + candidate = proposal_json.rstrip() + if error.pos != len(candidate) or not candidate.startswith("{"): + raise ValueError("Proposal does not match the GEWU v1 contract") from error + repaired_json = candidate + "}" + try: + parsed = json.loads(repaired_json, object_pairs_hook=_reject_duplicate_keys) + proposal = ProposalRevision.model_validate_json(repaired_json, strict=True) + except (ValueError, ValidationError) as repaired_error: + raise ValueError("Proposal does not match the GEWU v1 contract") from repaired_error + return proposal, True except (ValueError, ValidationError) as error: raise ValueError("Proposal does not match the GEWU v1 contract") from error if not isinstance(parsed, dict): raise ValueError("Proposal must be a JSON object") - return proposal + return proposal, False def _parse_run_request[RequestT: (RunCreateRequest, RunCheckpointRequest)]( diff --git a/tests/api/test_mcp_contribution.py b/tests/api/test_mcp_contribution.py index e95fc96..ea2d70e 100644 --- a/tests/api/test_mcp_contribution.py +++ b/tests/api/test_mcp_contribution.py @@ -252,6 +252,38 @@ def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None: assert prepared_proposal.payload_digest == proposal_payload_digest( prepared_proposal ) + truncated_evaluation = await mcp.call_tool( + "proposal_evaluate", + {"proposal_json": prepared_payload["proposal_json"][:-1]}, + ) + assert not truncated_evaluation.is_error, truncated_evaluation.content + truncated_evaluation_payload = json.loads( + _text_payload(truncated_evaluation) + ) + assert truncated_evaluation_payload["proposal_id"] == ( + prepared_proposal.proposal_id + ) + assert truncated_evaluation_payload["input_repaired"] is True + malformed_prepare = await mcp.call_tool( + "proposal_prepare", {"proposal_json": '{"schema_version":'} + ) + assert not malformed_prepare.is_error, malformed_prepare.content + malformed_prepare_payload = json.loads(_text_payload(malformed_prepare)) + assert malformed_prepare_payload["status"] == "invalid" + assert malformed_prepare_payload["issues"][0]["code"] == ( + "invalid_proposal_draft" + ) + malformed_evaluation = await mcp.call_tool( + "proposal_evaluate", {"proposal_json": '{"schema_version":'} + ) + assert not malformed_evaluation.is_error, malformed_evaluation.content + malformed_evaluation_payload = json.loads( + _text_payload(malformed_evaluation) + ) + assert malformed_evaluation_payload["status"] == "invalid" + assert malformed_evaluation_payload["findings"][0]["code"] == ( + "PROPOSAL_JSON_INVALID" + ) invalid_draft = json.loads(json.dumps(draft)) invalid_evidence = invalid_draft["operations"][1]["node"]["claims"][0][ "evidence" @@ -928,12 +960,13 @@ def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None: submitted = await mcp.call_tool( "proposal_submit", - {"proposal_json": candidate.model_dump_json()}, + {"proposal_json": candidate.model_dump_json()[:-1]}, ) assert not submitted.is_error, submitted.content outcome = json.loads(_text_payload(submitted)) assert outcome["status"] == "accepted" assert outcome["validation_status"] == "eligible" + assert outcome["input_repaired"] is True assert outcome["receipt"] is not None accepted_head = repository.head() retried = await mcp.call_tool(