fix(mcp): recover truncated prepared proposals
CI / quality (push) Canceled after 0s

This commit is contained in:
vasily committed 2026-10-15 12:09:04 +08:00
1 parent 34f4923566
commit 146aa3a281
3 files changed
+98 -10

No files matched your search

+63 -8
View File
@@ -1285,10 +1285,10 @@ def create_mcp_server(
@server.tool(name="proposal_submit", structured_output=False) @server.tool(name="proposal_submit", structured_output=False)
def proposal_submit(proposal_json: str, ctx: Context) -> str: 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) principal = _principal(ctx, identity_provider)
proposal = _parse_proposal(proposal_json) proposal, input_repaired = _parse_proposal(proposal_json)
context = context_provider() context = context_provider()
_authorize_proposal(principal, proposal, context) _authorize_proposal(principal, proposal, context)
staged_extractions: dict[str, ExtractionRecord] = {} staged_extractions: dict[str, ExtractionRecord] = {}
@@ -1321,6 +1321,7 @@ def create_mcp_server(
"status": "accepted" if outcome.receipt else outcome.validation.status, "status": "accepted" if outcome.receipt else outcome.validation.status,
"validation_status": outcome.validation.status, "validation_status": outcome.validation.status,
"eligible_for_auto_accept": outcome.validation.eligible_for_auto_accept, "eligible_for_auto_accept": outcome.validation.eligible_for_auto_accept,
"input_repaired": input_repaired,
"findings": [ "findings": [
finding.model_dump(mode="json") for finding in outcome.validation.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") raise ValueError("Proposal exceeds the supported request size")
try: try:
draft = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys) draft = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys)
except (ValueError, TypeError) as error: except (ValueError, TypeError):
raise ValueError("Proposal draft must be valid JSON") from error 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): if not isinstance(draft, dict):
raise ValueError("Proposal draft must be a JSON object") raise ValueError("Proposal draft must be a JSON object")
try: try:
@@ -1378,10 +1391,36 @@ def create_mcp_server(
@server.tool(name="proposal_evaluate", structured_output=False) @server.tool(name="proposal_evaluate", structured_output=False)
def proposal_evaluate(proposal_json: str, ctx: Context) -> str: 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) 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() context = context_provider()
_authorize_proposal(principal, proposal, context) _authorize_proposal(principal, proposal, context)
staged_extractions: dict[str, ExtractionRecord] = {} staged_extractions: dict[str, ExtractionRecord] = {}
@@ -1408,6 +1447,7 @@ def create_mcp_server(
"revision": proposal.revision, "revision": proposal.revision,
"status": report.status, "status": report.status,
"eligible_for_auto_accept": report.eligible_for_auto_accept, "eligible_for_auto_accept": report.eligible_for_auto_accept,
"input_repaired": input_repaired,
"findings": [finding.model_dump(mode="json") for finding in report.findings], "findings": [finding.model_dump(mode="json") for finding in report.findings],
"affected_policy_ids": report.affected_policy_ids, "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 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: if len(proposal_json.encode("utf-8")) > 1_000_000:
raise ValueError("Proposal exceeds the supported request size") raise ValueError("Proposal exceeds the supported request size")
try: try:
parsed = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys) parsed = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys)
proposal = ProposalRevision.model_validate_json(proposal_json, strict=True) 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: except (ValueError, ValidationError) as error:
raise ValueError("Proposal does not match the GEWU v1 contract") from error raise ValueError("Proposal does not match the GEWU v1 contract") from error
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
raise ValueError("Proposal must be a JSON object") raise ValueError("Proposal must be a JSON object")
return proposal return proposal, False
def _parse_run_request[RequestT: (RunCreateRequest, RunCheckpointRequest)]( def _parse_run_request[RequestT: (RunCreateRequest, RunCheckpointRequest)](
+34 -1
View File
@@ -252,6 +252,38 @@ def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None:
assert prepared_proposal.payload_digest == proposal_payload_digest( assert prepared_proposal.payload_digest == proposal_payload_digest(
prepared_proposal 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_draft = json.loads(json.dumps(draft))
invalid_evidence = invalid_draft["operations"][1]["node"]["claims"][0][ invalid_evidence = invalid_draft["operations"][1]["node"]["claims"][0][
"evidence" "evidence"
@@ -928,12 +960,13 @@ def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None:
submitted = await mcp.call_tool( submitted = await mcp.call_tool(
"proposal_submit", "proposal_submit",
{"proposal_json": candidate.model_dump_json()}, {"proposal_json": candidate.model_dump_json()[:-1]},
) )
assert not submitted.is_error, submitted.content assert not submitted.is_error, submitted.content
outcome = json.loads(_text_payload(submitted)) outcome = json.loads(_text_payload(submitted))
assert outcome["status"] == "accepted" assert outcome["status"] == "accepted"
assert outcome["validation_status"] == "eligible" assert outcome["validation_status"] == "eligible"
assert outcome["input_repaired"] is True
assert outcome["receipt"] is not None assert outcome["receipt"] is not None
accepted_head = repository.head() accepted_head = repository.head()
retried = await mcp.call_tool( retried = await mcp.call_tool(