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)
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)](