You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/09/26 12:27:33 UTC
(hugegraph-ai) 06/11: feat(mcp): persist confirmed write plans
This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git
commit 5132b59443deee7945b03e4a038993cb381e339d
Author: duyifeng01 <du...@baidu.com>
AuthorDate: Fri Sep 4 23:08:49 2026 +0800
feat(mcp): persist confirmed write plans
- Add durable server-issued plans and operation receipts.
- Coordinate confirmation, replay protection, and reconciliation.
- Test confirmed-write safety and failure outcomes.
---
.../hugegraph_mcp/confirmable_workflow.py | 205 ++++-
hugegraph-mcp/hugegraph_mcp/confirmation_store.py | 196 ++++-
hugegraph-mcp/hugegraph_mcp/plan_store.py | 854 +++++++++++++++++++++
hugegraph-mcp/hugegraph_mcp/reconciler.py | 202 +++++
hugegraph-mcp/hugegraph_mcp/server.py | 297 ++++---
.../hugegraph_mcp/tools/graph_data_execute.py | 485 ++++++++----
.../hugegraph_mcp/tools/graph_data_gremlin.py | 25 +-
.../hugegraph_mcp/tools/graph_data_validate.py | 89 ++-
.../hugegraph_mcp/tools/graph_write_adapter.py | 446 +++++++++++
.../hugegraph_mcp/tools/graph_write_plan.py | 230 ++++++
.../hugegraph_mcp/tools/ingest_graph_data.py | 448 ++++-------
.../hugegraph_mcp/tools/manage_graph_data.py | 308 ++++++--
hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py | 488 ++++++------
.../hugegraph_mcp/tools/mutate_graph_properties.py | 337 ++------
.../hugegraph_mcp/tools/property_validation.py | 198 +++++
.../hugegraph_mcp/tools/schema_write_adapter.py | 214 ++++++
hugegraph-mcp/hugegraph_mcp/write_contract.py | 109 +++
hugegraph-mcp/hugegraph_mcp/write_executor.py | 317 ++++++++
hugegraph-mcp/hugegraph_mcp/write_plan.py | 321 ++++++++
.../tests/contracts/write_confirmation_v2.json | 42 +
.../tests/integration/test_real_write_path.py | 634 ++++++++++-----
hugegraph-mcp/tests/test_confirm_write.py | 243 ++++++
hugegraph-mcp/tests/test_graph_write_workflow.py | 408 ++++++++++
hugegraph-mcp/tests/test_ingest_graph_data.py | 377 +++++----
hugegraph-mcp/tests/test_manage_graph_data.py | 652 +++++++++++-----
hugegraph-mcp/tests/test_manage_schema.py | 682 ++++++++--------
.../tests/test_mutate_graph_properties_tool.py | 355 +++------
hugegraph-mcp/tests/test_plan_hash.py | 234 +++---
hugegraph-mcp/tests/test_plan_store_interface.py | 63 ++
hugegraph-mcp/tests/test_plan_store_migration.py | 440 +++++++++++
hugegraph-mcp/tests/test_property_validation.py | 170 ++++
hugegraph-mcp/tests/test_reconciler.py | 217 ++++++
hugegraph-mcp/tests/test_schema_write_workflow.py | 145 ++++
hugegraph-mcp/tests/test_v1_stable_tools.py | 243 ++++--
.../tests/test_write_confirmation_contract.py | 165 ++++
hugegraph-mcp/tests/test_write_executor_faults.py | 363 +++++++++
hugegraph-mcp/tests/test_write_plan.py | 224 ++++++
37 files changed, 8848 insertions(+), 2578 deletions(-)
diff --git a/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py b/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py
index 24815bc2..2087852e 100644
--- a/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py
+++ b/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py
@@ -13,6 +13,7 @@
"""Small helpers for dry-run -> plan_hash -> confirm write workflows."""
+import time
from typing import Any
from hugegraph_mcp.config import MCPConfig
@@ -24,17 +25,24 @@ from hugegraph_mcp.confirmation_store import (
ConfirmationStore,
ConfirmationStoreUnavailableError,
)
-from hugegraph_mcp.envelope import ErrorType, envelope_err
+from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok
from hugegraph_mcp.plan_hash import PlanContext, verify_plan_hash
+from hugegraph_mcp.write_plan import ApplyStatus, PlanStatus
-def issue_plan(context: PlanContext, plan_hash: str) -> dict[str, Any] | None:
+def issue_plan(
+ context: PlanContext,
+ plan_hash: str,
+ *,
+ plan_payload: Any | None = None,
+) -> dict[str, Any] | None:
"""Persist a server-issued plan before its dry-run response is returned."""
try:
ConfirmationStore.from_config().issue(
nonce=context.nonce,
plan_hash=plan_hash,
expires_at=context.expires_at,
+ plan_payload=plan_payload,
)
except ConfirmationPlanExpiredError:
return plan_hash_error(
@@ -61,25 +69,99 @@ def issue_plan(context: PlanContext, plan_hash: str) -> dict[str, Any] | None:
return None
-def replayed_plan_error(
- nonce: str | None, *, source: str | None = None
-) -> dict[str, Any] | None:
+def load_issued_plan(
+ *, nonce: str | None, plan_hash: str | None, expires_at: float | None
+) -> tuple[Any | None, dict[str, Any] | None]:
+ """Load a server-issued payload without consuming its one-shot confirmation."""
+ if not nonce or not plan_hash or expires_at is None:
+ return None, plan_hash_error(
+ error_type=ErrorType.PLAN_HASH_MISMATCH,
+ details={"reason": "Plan confirmation fields are incomplete."},
+ mismatch_message="No server-issued dry-run plan matches this confirmation.",
+ suggestion="Run dry_run again and use all returned confirmation fields.",
+ )
+ try:
+ normalized_expires_at = int(expires_at)
+ except (TypeError, ValueError, OverflowError):
+ return None, plan_hash_error(
+ error_type=ErrorType.PLAN_HASH_MISMATCH,
+ details={"reason": "Plan expiry is invalid."},
+ mismatch_message="The confirmation plan expiry is invalid.",
+ suggestion="Run dry_run again and use the returned confirmation plan.",
+ )
+ if normalized_expires_at < int(time.time()):
+ return None, plan_hash_error(
+ error_type=ErrorType.PLAN_EXPIRED,
+ details={"reason": "The server-issued plan has expired."},
+ mismatch_message="The server-issued plan has expired.",
+ expired_message="The server-issued plan has expired.",
+ suggestion="Run dry_run again and use the returned confirmation plan.",
+ )
+ try:
+ payload = ConfirmationStore.from_config().issued_payload(
+ nonce=nonce,
+ plan_hash=plan_hash,
+ expires_at=normalized_expires_at,
+ )
+ except ConfirmationNotIssuedError:
+ error_type = ErrorType.PLAN_HASH_MISMATCH
+ reason = "No server-issued dry-run plan matches this confirmation."
+ except ConfirmationPlanMismatchError:
+ error_type = ErrorType.PLAN_HASH_MISMATCH
+ reason = "The confirmation does not match the server-issued plan."
+ except ConfirmationPlanExpiredError:
+ error_type = ErrorType.PLAN_EXPIRED
+ reason = "The server-issued plan has expired."
+ except ConfirmationStoreUnavailableError:
+ error_type = ErrorType.SERVER_ERROR
+ reason = "Confirmation state is unavailable."
+ else:
+ return payload, None
+ return None, plan_hash_error(
+ error_type=error_type,
+ details={"reason": reason},
+ mismatch_message="No server-issued dry-run plan matches this confirmation.",
+ expired_message="The server-issued plan has expired.",
+ suggestion="Run dry_run again and use the returned confirmation plan.",
+ )
+
+
+def replayed_plan_error(nonce: str | None, *, source: str | None = None) -> dict[str, Any] | None:
"""Return a replay error before live-state validation when already consumed."""
try:
- consumed = ConfirmationStore.from_config().has_consumed(nonce)
+ store = ConfirmationStore.from_config()
+ consumed = store.has_consumed(nonce)
except ConfirmationStoreUnavailableError:
# Unknown state is not treated as unused. Full validation continues and
# the atomic consume remains fail-closed before the first write.
return None
if not consumed:
return None
+ try:
+ operation = store.operation_for_nonce(nonce)
+ except ConfirmationStoreUnavailableError:
+ operation = None
+ unresolved_statuses = {
+ PlanStatus.EXECUTING.value,
+ PlanStatus.UNKNOWN.value,
+ }
+ if operation and operation.get("status") in unresolved_statuses:
+ return envelope_err(
+ ErrorType.WRITE_OUTCOME_UNKNOWN,
+ "The prior write attempt has no confirmed outcome; reconcile before retrying.",
+ suggestion="Inspect the target state and query the persisted write status.",
+ retryable=False,
+ details={
+ "status": PlanStatus.UNKNOWN.value,
+ "plan_hash": operation.get("plan_hash"),
+ "reconciliation_required": True,
+ },
+ source=source or "hugegraph-mcp",
+ )
return plan_hash_error(
error_type=ErrorType.PLAN_ALREADY_USED,
details={
- "reason": (
- "This confirmation has already been used. Inspect the target "
- "state and run dry_run again."
- )
+ "reason": ("This confirmation has already been used. Inspect the target state and run dry_run again.")
},
mismatch_message="This confirmation plan has already been used.",
suggestion="Inspect the current target state, then run dry_run again.",
@@ -87,6 +169,49 @@ def replayed_plan_error(
)
+def record_write_outcome(
+ *,
+ plan_hash: str,
+ status: PlanStatus | ApplyStatus | str,
+ receipt: dict[str, Any],
+) -> bool:
+ """Persist an execution receipt; return False if durability is unavailable."""
+ persisted_status = PlanStatus(status.value if isinstance(status, (PlanStatus, ApplyStatus)) else status)
+ try:
+ ConfirmationStore.from_config().record_outcome(
+ plan_hash=plan_hash,
+ status=persisted_status,
+ receipt=receipt,
+ )
+ except (ConfirmationNotIssuedError, ConfirmationStoreUnavailableError):
+ return False
+ return True
+
+
+def get_write_status(plan_hash: str) -> dict[str, Any]:
+ """Read the durable state of a confirmed write without exposing its payload."""
+ try:
+ operation = ConfirmationStore.from_config().operation_for_plan(plan_hash)
+ except ConfirmationStoreUnavailableError:
+ return envelope_err(
+ ErrorType.SERVER_ERROR,
+ "Write status storage is unavailable.",
+ retryable=False,
+ )
+ if operation is None:
+ return envelope_err(
+ ErrorType.NOT_FOUND,
+ "No persisted write operation matches this plan hash.",
+ retryable=False,
+ )
+ public = dict(operation)
+ public.pop("plan", None)
+ if public.get("status") == PlanStatus.EXECUTING.value:
+ public["status"] = PlanStatus.UNKNOWN.value
+ public["reconciliation_required"] = True
+ return envelope_ok(public)
+
+
def verify_and_consume_plan(
*,
submitted_hash: str,
@@ -99,6 +224,31 @@ def verify_and_consume_plan(
extra_context: dict[str, Any] | None = None,
) -> tuple[bool, ErrorType | None, dict[str, Any] | None]:
"""Validate a plan without side effects, then atomically consume its nonce."""
+ valid, error_type, details, _payload = verify_and_consume_issued_plan(
+ submitted_hash=submitted_hash,
+ tool_name=tool_name,
+ mode=mode,
+ payload_digest=payload_digest,
+ schema_hash=schema_hash,
+ nonce=nonce,
+ expires_at=expires_at,
+ extra_context=extra_context,
+ )
+ return valid, error_type, details
+
+
+def verify_and_consume_issued_plan(
+ *,
+ submitted_hash: str,
+ tool_name: str,
+ mode: str,
+ payload_digest: str,
+ schema_hash: str | None = None,
+ nonce: str | None = None,
+ expires_at: float | None = None,
+ extra_context: dict[str, Any] | None = None,
+) -> tuple[bool, ErrorType | None, dict[str, Any] | None, Any | None]:
+ """Validate, atomically consume, and return the exact persisted payload."""
valid, error_type, details = verify_plan_hash(
submitted_hash=submitted_hash,
tool_name=tool_name,
@@ -110,17 +260,18 @@ def verify_and_consume_plan(
extra_context=extra_context,
)
if not valid:
- return False, error_type, details
+ return False, error_type, details, None
if MCPConfig.from_env().is_readonly():
return (
False,
ErrorType.READONLY_VIOLATION,
{"reason": "Write confirmation is disabled in readonly mode."},
+ None,
)
try:
- ConfirmationStore.from_config().consume(
+ payload = ConfirmationStore.from_config().consume(
nonce=nonce or "",
plan_hash=submitted_hash,
expires_at=int(expires_at or 0),
@@ -129,43 +280,38 @@ def verify_and_consume_plan(
return (
False,
ErrorType.PLAN_ALREADY_USED,
- {
- "reason": (
- "This confirmation has already been used. Inspect the target "
- "state and run dry_run again."
- )
- },
+ {"reason": ("This confirmation has already been used. Inspect the target state and run dry_run again.")},
+ None,
)
except ConfirmationNotIssuedError:
return (
False,
ErrorType.PLAN_HASH_MISMATCH,
{"reason": "No server-issued dry-run plan matches this confirmation."},
+ None,
)
except ConfirmationPlanMismatchError:
return (
False,
ErrorType.PLAN_HASH_MISMATCH,
{"reason": "The confirmation does not match the server-issued plan."},
+ None,
)
except ConfirmationPlanExpiredError:
return (
False,
ErrorType.PLAN_EXPIRED,
{"reason": "The server-issued plan has expired."},
+ None,
)
except ConfirmationStoreUnavailableError:
return (
False,
ErrorType.SERVER_ERROR,
- {
- "reason": (
- "Confirmation state is unavailable. The write was blocked "
- "before execution."
- )
- },
+ {"reason": ("Confirmation state is unavailable. The write was blocked before execution.")},
+ None,
)
- return True, None, None
+ return True, None, None, payload
def mark_readonly_preview(
@@ -207,15 +353,10 @@ def plan_hash_error(
message = expired_message
elif resolved_error_type == ErrorType.PLAN_ALREADY_USED:
message = "This confirmation plan has already been used."
- suggestion = (
- "Inspect the current target state, then run dry_run again and use the "
- "new confirmation plan."
- )
+ suggestion = "Inspect the current target state, then run dry_run again and use the new confirmation plan."
elif resolved_error_type == ErrorType.SERVER_ERROR:
message = "Confirmation state could not be recorded safely."
- suggestion = (
- "Restore writable persistent confirmation state, then run dry_run again."
- )
+ suggestion = "Restore writable persistent confirmation state, then run dry_run again."
else:
message = mismatch_message
return envelope_err(
diff --git a/hugegraph-mcp/hugegraph_mcp/confirmation_store.py b/hugegraph-mcp/hugegraph_mcp/confirmation_store.py
index 6a543776..c1f9376a 100644
--- a/hugegraph-mcp/hugegraph_mcp/confirmation_store.py
+++ b/hugegraph-mcp/hugegraph_mcp/confirmation_store.py
@@ -18,13 +18,16 @@
from __future__ import annotations
import hashlib
+import json
import os
import sqlite3
import time
from pathlib import Path
+from typing import Any
from hugegraph_mcp.config import MCPConfig
from hugegraph_mcp.plan_hash import DEFAULT_PLAN_TTL_SECONDS
+from hugegraph_mcp.write_plan import PlanStatus
class ConfirmationAlreadyUsedError(Exception):
@@ -74,15 +77,80 @@ class ConfirmationStore:
""",
(nonce_digest,),
).fetchone()
- except (OSError, sqlite3.Error) as exc:
+ except (OSError, sqlite3.Error, json.JSONDecodeError) as exc:
raise ConfirmationStoreUnavailableError from exc
return row is not None
- def issue(self, *, nonce: str, plan_hash: str, expires_at: int) -> None:
+ def operation_for_nonce(self, nonce: str | None) -> dict[str, Any] | None:
+ """Return durable execution state for a consumed nonce."""
+ if not nonce or not self.database_path.exists():
+ return None
+ nonce_digest = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
+ return self._operation_row("nonce_digest", nonce_digest)
+
+ def operation_for_plan(self, plan_hash: str | None) -> dict[str, Any] | None:
+ """Return durable execution state for a plan hash."""
+ if not plan_hash or not self.database_path.exists():
+ return None
+ return self._operation_row("plan_hash", plan_hash)
+
+ def record_outcome(
+ self,
+ *,
+ plan_hash: str,
+ status: PlanStatus,
+ receipt: dict[str, Any],
+ ) -> None:
+ """Persist a final or reconcilable outcome after execution."""
+ try:
+ receipt_json = json.dumps(
+ receipt,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ self._prepare_storage()
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("PRAGMA synchronous = FULL")
+ self._ensure_schema(connection)
+ updated = connection.execute(
+ """
+ UPDATE write_operations
+ SET status = ?, receipt_json = ?, updated_at = ?
+ WHERE plan_hash = ?
+ """,
+ (status.value, receipt_json, int(time.time()), plan_hash),
+ )
+ if updated.rowcount != 1:
+ raise ConfirmationNotIssuedError
+ connection.commit()
+ except ConfirmationNotIssuedError:
+ raise
+ except (OSError, sqlite3.Error, TypeError) as exc:
+ raise ConfirmationStoreUnavailableError from exc
+
+ def issue(
+ self,
+ *,
+ nonce: str,
+ plan_hash: str,
+ expires_at: int,
+ plan_payload: Any | None = None,
+ ) -> None:
"""Persist a server-issued dry-run plan before returning it to the caller."""
nonce_digest = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
issued_at = int(time.time())
expires_at = int(expires_at)
+ payload_json = (
+ json.dumps(
+ plan_payload,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ if plan_payload is not None
+ else None
+ )
if expires_at <= issued_at or expires_at > issued_at + DEFAULT_PLAN_TTL_SECONDS:
raise ConfirmationPlanExpiredError
@@ -95,31 +163,66 @@ class ConfirmationStore:
connection.execute(
"""
INSERT INTO issued_confirmations (
- nonce_digest, plan_hash, expires_at, issued_at
- ) VALUES (?, ?, ?, ?)
+ nonce_digest, plan_hash, expires_at, issued_at, plan_payload_json
+ ) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(nonce_digest) DO UPDATE SET
plan_hash = excluded.plan_hash,
expires_at = excluded.expires_at,
- issued_at = excluded.issued_at
+ issued_at = excluded.issued_at,
+ plan_payload_json = excluded.plan_payload_json
WHERE issued_confirmations.plan_hash = excluded.plan_hash
""",
- (nonce_digest, plan_hash, expires_at, issued_at),
+ (nonce_digest, plan_hash, expires_at, issued_at, payload_json),
)
row = connection.execute(
"""
- SELECT plan_hash, expires_at FROM issued_confirmations
+ SELECT plan_hash, expires_at, plan_payload_json
+ FROM issued_confirmations
WHERE nonce_digest = ?
""",
(nonce_digest,),
).fetchone()
- if row != (plan_hash, expires_at):
+ if row != (plan_hash, expires_at, payload_json):
raise ConfirmationPlanMismatchError
except (ConfirmationPlanExpiredError, ConfirmationPlanMismatchError):
raise
- except (OSError, sqlite3.Error) as exc:
+ except (OSError, sqlite3.Error, json.JSONDecodeError) as exc:
raise ConfirmationStoreUnavailableError from exc
- def consume(self, *, nonce: str, plan_hash: str, expires_at: int) -> None:
+ def issued_payload(self, *, nonce: str, plan_hash: str, expires_at: int) -> Any | None:
+ """Load the immutable server-side payload bound to an issued plan."""
+ nonce_digest = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
+ current_time = int(time.time())
+ try:
+ if not self.database_path.exists():
+ raise ConfirmationNotIssuedError
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ self._ensure_schema(connection)
+ row = connection.execute(
+ """
+ SELECT plan_hash, expires_at, issued_at, plan_payload_json
+ FROM issued_confirmations WHERE nonce_digest = ?
+ """,
+ (nonce_digest,),
+ ).fetchone()
+ if row is None:
+ raise ConfirmationNotIssuedError
+ issued_hash, issued_expires_at, issued_at, payload_json = row
+ if issued_hash != plan_hash or issued_expires_at != int(expires_at):
+ raise ConfirmationPlanMismatchError
+ if current_time > issued_expires_at or issued_expires_at > issued_at + DEFAULT_PLAN_TTL_SECONDS:
+ raise ConfirmationPlanExpiredError
+ return json.loads(payload_json) if payload_json is not None else None
+ except (
+ ConfirmationNotIssuedError,
+ ConfirmationPlanMismatchError,
+ ConfirmationPlanExpiredError,
+ ):
+ raise
+ except (OSError, sqlite3.Error, json.JSONDecodeError) as exc:
+ raise ConfirmationStoreUnavailableError from exc
+
+ def consume(self, *, nonce: str, plan_hash: str, expires_at: int) -> Any | None:
"""Atomically validate and consume a server-issued dry-run plan."""
nonce_digest = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
consumed_at = int(time.time())
@@ -146,20 +249,17 @@ class ConfirmationStore:
raise ConfirmationAlreadyUsedError
issued = connection.execute(
"""
- SELECT plan_hash, expires_at, issued_at
+ SELECT plan_hash, expires_at, issued_at, plan_payload_json
FROM issued_confirmations WHERE nonce_digest = ?
""",
(nonce_digest,),
).fetchone()
if issued is None:
raise ConfirmationNotIssuedError
- issued_hash, issued_expires_at, issued_at = issued
+ issued_hash, issued_expires_at, issued_at, payload_json = issued
if issued_hash != plan_hash or issued_expires_at != int(expires_at):
raise ConfirmationPlanMismatchError
- if (
- consumed_at > issued_expires_at
- or issued_expires_at > issued_at + DEFAULT_PLAN_TTL_SECONDS
- ):
+ if consumed_at > issued_expires_at or issued_expires_at > issued_at + DEFAULT_PLAN_TTL_SECONDS:
raise ConfirmationPlanExpiredError
connection.execute(
"""
@@ -169,11 +269,27 @@ class ConfirmationStore:
""",
(nonce_digest, plan_hash, int(expires_at), consumed_at),
)
+ connection.execute(
+ """
+ INSERT INTO write_operations (
+ plan_hash, nonce_digest, plan_payload_json,
+ status, receipt_json, updated_at
+ ) VALUES (?, ?, ?, ?, NULL, ?)
+ """,
+ (
+ plan_hash,
+ nonce_digest,
+ payload_json,
+ PlanStatus.EXECUTING.value,
+ consumed_at,
+ ),
+ )
connection.execute(
"DELETE FROM issued_confirmations WHERE nonce_digest = ?",
(nonce_digest,),
)
connection.commit()
+ return json.loads(payload_json) if payload_json is not None else None
except sqlite3.IntegrityError as exc:
raise ConfirmationAlreadyUsedError from exc
except (
@@ -183,12 +299,10 @@ class ConfirmationStore:
ConfirmationPlanExpiredError,
):
raise
- except (OSError, sqlite3.Error) as exc:
+ except (OSError, sqlite3.Error, json.JSONDecodeError) as exc:
raise ConfirmationStoreUnavailableError from exc
- def _cleanup_expired(
- self, connection: sqlite3.Connection, current_time: int
- ) -> None:
+ def _cleanup_expired(self, connection: sqlite3.Connection, current_time: int) -> None:
"""Best-effort cleanup that never authorizes or blocks a current plan."""
connection.execute(
"DELETE FROM consumed_confirmations WHERE expires_at < ?",
@@ -218,12 +332,52 @@ class ConfirmationStore:
nonce_digest TEXT PRIMARY KEY,
plan_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
- issued_at INTEGER NOT NULL
+ issued_at INTEGER NOT NULL,
+ plan_payload_json TEXT
+ )
+ """
+ )
+ columns = {row[1] for row in connection.execute("PRAGMA table_info(issued_confirmations)")}
+ if "plan_payload_json" not in columns:
+ connection.execute("ALTER TABLE issued_confirmations ADD COLUMN plan_payload_json TEXT")
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS write_operations (
+ plan_hash TEXT PRIMARY KEY,
+ nonce_digest TEXT NOT NULL UNIQUE,
+ plan_payload_json TEXT,
+ status TEXT NOT NULL,
+ receipt_json TEXT,
+ updated_at INTEGER NOT NULL
)
"""
)
connection.commit()
+ def _operation_row(self, field: str, value: str) -> dict[str, Any] | None:
+ if field not in {"nonce_digest", "plan_hash"}:
+ raise ValueError("Unsupported operation lookup field")
+ query = (
+ "SELECT plan_hash, status, plan_payload_json, receipt_json, updated_at "
+ f"FROM write_operations WHERE {field} = ?"
+ )
+ try:
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ self._ensure_schema(connection)
+ row = connection.execute(query, (value,)).fetchone()
+ if row is None:
+ return None
+ plan_hash, status, payload_json, receipt_json, updated_at = row
+ return {
+ "plan_hash": plan_hash,
+ "status": status,
+ "plan": json.loads(payload_json) if payload_json else None,
+ "receipt": json.loads(receipt_json) if receipt_json else None,
+ "updated_at": updated_at,
+ }
+ except (OSError, sqlite3.Error, json.JSONDecodeError) as exc:
+ raise ConfirmationStoreUnavailableError from exc
+
def _prepare_storage(self) -> None:
self.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
_restrict_permissions(self.state_dir, 0o700)
diff --git a/hugegraph-mcp/hugegraph_mcp/plan_store.py b/hugegraph-mcp/hugegraph_mcp/plan_store.py
new file mode 100644
index 00000000..42ba3fa7
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/plan_store.py
@@ -0,0 +1,854 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Versioned SQLite persistence for immutable write plans and operations."""
+
+from __future__ import annotations
+
+import json
+import os
+import sqlite3
+import time
+import uuid
+from pathlib import Path
+from typing import Any, Protocol, runtime_checkable
+
+from hugegraph_mcp.write_plan import (
+ ApplyReceipt,
+ ApplyStatus,
+ GraphTarget,
+ OperationPlan,
+ PlanStatus,
+ WritePlan,
+ aggregate_plan_status,
+ can_transition,
+ canonical_plan_json,
+)
+
+SCHEMA_VERSION = 3
+
+
+class PlanTransitionError(Exception):
+ """The requested plan transition does not match durable state."""
+
+
+class PlanStoreUnavailableError(Exception):
+ """The configured durable plan store cannot safely serve writes."""
+
+
+@runtime_checkable
+class PlanStore(Protocol):
+ def prepare(self) -> None: ...
+
+ def save_plan(self, plan: WritePlan) -> None: ...
+
+ def get_plan(self, plan_id: str) -> WritePlan | None: ...
+
+ def get_plan_record(self, plan_id: str) -> dict[str, Any] | None: ...
+
+ def transition_plan(
+ self,
+ plan_id: str,
+ *,
+ expected: PlanStatus,
+ target: PlanStatus,
+ ) -> WritePlan: ...
+
+ def record_receipt(
+ self,
+ receipt: ApplyReceipt,
+ *,
+ owner_token: str | None = None,
+ attempt_token: str | None = None,
+ ) -> None: ...
+
+ def claim_plan(
+ self,
+ plan_id: str,
+ *,
+ owner_token: str | None = None,
+ lease_seconds: int = 30,
+ resume: bool = False,
+ ) -> tuple[WritePlan, bool]: ...
+
+ def claim_operation(
+ self,
+ plan_id: str,
+ operation_id: str,
+ *,
+ owner_token: str | None = None,
+ attempt_token: str | None = None,
+ lease_seconds: int = 30,
+ ) -> int: ...
+
+ def begin_reconcile(self, plan_id: str) -> WritePlan: ...
+
+ def record_reconciliation_receipt(self, receipt: ApplyReceipt) -> None: ...
+
+
+class SQLitePlanStore:
+ DATABASE_NAME = "write_plans.sqlite3"
+ LEGACY_DATABASE_NAME = "confirmations.sqlite3"
+
+ def __init__(self, state_dir: Path):
+ self.state_dir = Path(state_dir)
+ self.database_path = self.state_dir / self.DATABASE_NAME
+ self.legacy_database_path = self.state_dir / self.LEGACY_DATABASE_NAME
+
+ @classmethod
+ def from_config(cls, cfg=None) -> SQLitePlanStore:
+ if cfg is None:
+ from hugegraph_mcp.config import MCPConfig
+
+ cfg = MCPConfig.from_env()
+ return cls(cfg.state_dir)
+
+ @staticmethod
+ def legacy_plan_id(plan_hash: str) -> str:
+ return f"wp_legacy_{plan_hash}"
+
+ @staticmethod
+ def legacy_operation_id(plan_hash: str) -> str:
+ return f"op_legacy_{plan_hash}"
+
+ def prepare(self) -> None:
+ self._prepare_storage()
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("PRAGMA foreign_keys = ON")
+ connection.execute("PRAGMA synchronous = FULL")
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
+ if version > SCHEMA_VERSION:
+ raise PlanStoreUnavailableError(
+ f"write plan database schema {version} is newer than supported {SCHEMA_VERSION}"
+ )
+ self._migrate_schema(connection, version)
+ self._migrate_legacy(connection)
+ connection.commit()
+
+ def save_plan(self, plan: WritePlan) -> None:
+ """Persist one immutable plan and its ordered operations atomically."""
+ self.prepare()
+ payload_json = canonical_plan_json(plan)
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("PRAGMA foreign_keys = ON")
+ connection.execute("BEGIN IMMEDIATE")
+ connection.execute(
+ """
+ INSERT INTO write_plans (
+ plan_id, tool_name, graph_target_json, principal,
+ payload_json, payload_digest, schema_fingerprint, status,
+ created_at, expires_at, confirmed_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
+ """,
+ (
+ plan.plan_id,
+ plan.tool_name,
+ self._canonical_json(plan.graph_target.to_dict()),
+ plan.principal,
+ payload_json,
+ plan.payload_digest,
+ plan.schema_fingerprint,
+ plan.status.value,
+ plan.created_at,
+ plan.expires_at,
+ ),
+ )
+ for ordinal, operation in enumerate(plan.operations):
+ connection.execute(
+ """
+ INSERT INTO write_operations (
+ operation_id, plan_id, ordinal, kind, payload_json,
+ status, attempt, receipt_json, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)
+ """,
+ (
+ operation.operation_id,
+ plan.plan_id,
+ ordinal,
+ operation.kind,
+ self._canonical_json(operation.to_dict()),
+ PlanStatus.ISSUED.value,
+ plan.created_at,
+ ),
+ )
+ connection.commit()
+
+ def get_plan(self, plan_id: str) -> WritePlan | None:
+ """Rehydrate an immutable canonical plan from durable storage."""
+ record = self.get_plan_record(plan_id)
+ if record is None:
+ return None
+ # Legacy confirmation rows lack the canonical graph target and
+ # operation state required for safe execution or reconciliation.
+ if record["tool_name"] == "legacy":
+ return None
+ operations = tuple(
+ OperationPlan(
+ operation_id=item["payload"]["operation_id"],
+ kind=item["payload"]["kind"],
+ target=item["payload"]["target"],
+ expected_state=item["payload"]["expected_state"],
+ desired_state=item["payload"]["desired_state"],
+ depends_on=tuple(item["payload"].get("depends_on") or ()),
+ idempotency_key=item["payload"].get("idempotency_key"),
+ )
+ for item in record["operations"]
+ )
+ target = record["graph_target"]
+ return WritePlan(
+ plan_id=record["plan_id"],
+ tool_name=record["tool_name"],
+ graph_target=GraphTarget(
+ graph_url=target["graph_url"],
+ graph_name=target["graph_name"],
+ graphspace=target.get("graphspace"),
+ ),
+ principal=record["principal"],
+ operations=operations,
+ payload_digest=record["payload_digest"],
+ schema_fingerprint=record["schema_fingerprint"],
+ status=PlanStatus(record["status"]),
+ created_at=record["created_at"],
+ expires_at=record["expires_at"],
+ )
+
+ def transition_plan(
+ self,
+ plan_id: str,
+ *,
+ expected: PlanStatus,
+ target: PlanStatus,
+ ) -> WritePlan:
+ """Compare-and-set one legal plan transition in SQLite."""
+ if not can_transition(expected, target):
+ raise PlanTransitionError(f"illegal transition: {expected} -> {target}")
+ self.prepare()
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ confirmed_at_sql = (
+ ", confirmed_at = COALESCE(confirmed_at, strftime('%s','now'))"
+ if target is PlanStatus.EXECUTING
+ else ""
+ )
+ updated = connection.execute(
+ f"""
+ UPDATE write_plans SET status = ?{confirmed_at_sql}
+ WHERE plan_id = ? AND status = ?
+ """,
+ (target.value, plan_id, expected.value),
+ )
+ if updated.rowcount != 1:
+ connection.rollback()
+ raise PlanTransitionError("durable plan state did not match the expected state")
+ connection.commit()
+ plan = self.get_plan(plan_id)
+ if plan is None:
+ raise PlanTransitionError("plan disappeared after transition")
+ return plan
+
+ def claim_plan(
+ self,
+ plan_id: str,
+ *,
+ owner_token: str | None = None,
+ lease_seconds: int = 30,
+ resume: bool = False,
+ ) -> tuple[WritePlan, bool]:
+ """Atomically elect one executor and persist its fencing lease."""
+ if lease_seconds <= 0:
+ raise ValueError("lease_seconds must be positive")
+ self.prepare()
+ owner_token = owner_token or uuid.uuid4().hex
+ now = int(time.time())
+ claimed = False
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ row = connection.execute(
+ "SELECT status, lease_expires_at FROM write_plans WHERE plan_id = ?",
+ (plan_id,),
+ ).fetchone()
+ if row is None:
+ connection.rollback()
+ raise PlanTransitionError("plan was not found")
+ current = PlanStatus(row[0])
+ claimable = {PlanStatus.ISSUED}
+ if resume:
+ claimable.update({PlanStatus.RETRYABLE_NOT_APPLIED, PlanStatus.PARTIAL})
+ lease_available = row[1] is None or int(row[1]) <= now
+ if current in claimable and lease_available:
+ target = PlanStatus.EXECUTING if current is not PlanStatus.PARTIAL else current
+ updated = connection.execute(
+ """
+ UPDATE write_plans
+ SET status = ?, confirmed_at = COALESCE(confirmed_at, ?),
+ lease_owner = ?, lease_expires_at = ?
+ WHERE plan_id = ? AND status = ?
+ AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
+ """,
+ (
+ target.value,
+ now,
+ owner_token,
+ now + lease_seconds,
+ plan_id,
+ current.value,
+ now,
+ ),
+ )
+ claimed = updated.rowcount == 1
+ connection.commit()
+ plan = self.get_plan(plan_id)
+ if plan is None:
+ raise PlanTransitionError("plan disappeared after claim")
+ return plan, claimed
+
+ def claim_operation(
+ self,
+ plan_id: str,
+ operation_id: str,
+ *,
+ owner_token: str | None = None,
+ attempt_token: str | None = None,
+ lease_seconds: int = 30,
+ ) -> int:
+ """Atomically claim one not-yet-applied operation and increment attempt."""
+ if lease_seconds <= 0:
+ raise ValueError("lease_seconds must be positive")
+ self.prepare()
+ claimable = {
+ PlanStatus.ISSUED.value,
+ PlanStatus.RETRYABLE_NOT_APPLIED.value,
+ }
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ plan_row = connection.execute(
+ "SELECT lease_owner, lease_expires_at FROM write_plans WHERE plan_id = ?",
+ (plan_id,),
+ ).fetchone()
+ now = int(time.time())
+ if plan_row is None or plan_row[1] is None or int(plan_row[1]) <= now:
+ connection.rollback()
+ raise PlanTransitionError("executor lease is absent or expired")
+ effective_owner = owner_token or str(plan_row[0])
+ if plan_row[0] != effective_owner:
+ connection.rollback()
+ raise PlanTransitionError("executor lease owner did not match")
+ row = connection.execute(
+ """
+ SELECT status, attempt FROM write_operations
+ WHERE plan_id = ? AND operation_id = ?
+ """,
+ (plan_id, operation_id),
+ ).fetchone()
+ if row is None or row[0] not in claimable:
+ connection.rollback()
+ raise PlanTransitionError("operation is not claimable")
+ attempt = int(row[1]) + 1
+ attempt_token = attempt_token or uuid.uuid4().hex
+ connection.execute(
+ """
+ UPDATE write_operations
+ SET status = ?, attempt = ?, attempt_token = ?, reconciled = 0,
+ updated_at = ?
+ WHERE plan_id = ? AND operation_id = ?
+ """,
+ (
+ PlanStatus.EXECUTING.value,
+ attempt,
+ attempt_token,
+ now,
+ plan_id,
+ operation_id,
+ ),
+ )
+ renewed = connection.execute(
+ """UPDATE write_plans SET lease_expires_at = ?
+ WHERE plan_id = ? AND lease_owner = ? AND lease_expires_at > ?""",
+ (now + lease_seconds, plan_id, effective_owner, now),
+ )
+ if renewed.rowcount != 1:
+ connection.rollback()
+ raise PlanTransitionError("executor lease could not be renewed")
+ connection.commit()
+ return attempt
+
+ def record_receipt(
+ self,
+ receipt: ApplyReceipt,
+ *,
+ owner_token: str | None = None,
+ attempt_token: str | None = None,
+ ) -> None:
+ """Persist one operation receipt and recompute the aggregate plan state."""
+ if not receipt.plan_id or not receipt.operation_id:
+ raise ValueError("canonical receipt requires plan_id and operation_id")
+ self.prepare()
+ operation_status = PlanStatus(receipt.status.value)
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ operation_row = connection.execute(
+ """
+ SELECT o.status, o.attempt, o.attempt_token, p.lease_owner,
+ p.lease_expires_at
+ FROM write_operations o JOIN write_plans p ON p.plan_id = o.plan_id
+ WHERE o.plan_id = ? AND o.operation_id = ?
+ """,
+ (receipt.plan_id, receipt.operation_id),
+ ).fetchone()
+ if operation_row is None:
+ connection.rollback()
+ raise PlanTransitionError("operation receipt target was not found")
+ effective_owner = owner_token or operation_row[3]
+ effective_attempt_token = attempt_token or receipt.attempt_token or operation_row[2]
+ if (
+ operation_row[0] != PlanStatus.EXECUTING.value
+ or int(operation_row[1]) != receipt.attempt
+ or not effective_attempt_token
+ or operation_row[2] != effective_attempt_token
+ or operation_row[3] != effective_owner
+ or operation_row[4] is None
+ or int(operation_row[4]) <= int(time.time())
+ ):
+ connection.rollback()
+ raise PlanTransitionError("stale or unfenced operation receipt")
+ updated = connection.execute(
+ """
+ UPDATE write_operations
+ SET status = ?, attempt = ?, receipt_json = ?, updated_at = ?
+ WHERE plan_id = ? AND operation_id = ? AND status = ?
+ AND attempt = ? AND attempt_token = ?
+ """,
+ (
+ operation_status.value,
+ receipt.attempt,
+ self._canonical_json(receipt.to_dict()),
+ receipt.committed_at or 0,
+ receipt.plan_id,
+ receipt.operation_id,
+ PlanStatus.EXECUTING.value,
+ receipt.attempt,
+ effective_attempt_token,
+ ),
+ )
+ if updated.rowcount != 1:
+ connection.rollback()
+ raise PlanTransitionError("operation receipt target was not found")
+ rows = connection.execute(
+ "SELECT status FROM write_operations WHERE plan_id = ? ORDER BY ordinal",
+ (receipt.plan_id,),
+ ).fetchall()
+ aggregate = self._aggregate_persisted_statuses([str(row[0]) for row in rows])
+ current_row = connection.execute(
+ "SELECT status FROM write_plans WHERE plan_id = ?",
+ (receipt.plan_id,),
+ ).fetchone()
+ if current_row is None:
+ connection.rollback()
+ raise PlanTransitionError("receipt plan was not found")
+ current = PlanStatus(current_row[0])
+ if current is not aggregate and not can_transition(current, aggregate):
+ connection.rollback()
+ raise PlanTransitionError(f"illegal receipt transition: {current} -> {aggregate}")
+ keep_lease = receipt.status in {
+ ApplyStatus.APPLIED,
+ ApplyStatus.ALREADY_APPLIED,
+ } and aggregate in {PlanStatus.EXECUTING, PlanStatus.PARTIAL}
+ connection.execute(
+ """UPDATE write_plans SET status = ?,
+ lease_owner = CASE WHEN ? THEN lease_owner ELSE NULL END,
+ lease_expires_at = CASE WHEN ? THEN lease_expires_at ELSE NULL END
+ WHERE plan_id = ?""",
+ (aggregate.value, keep_lease, keep_lease, receipt.plan_id),
+ )
+ connection.commit()
+
+ def begin_reconcile(self, plan_id: str) -> WritePlan:
+ """Fence an expired executor before any read-only reconciliation."""
+ self.prepare()
+ now = int(time.time())
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ row = connection.execute(
+ "SELECT status, lease_expires_at FROM write_plans WHERE plan_id = ?",
+ (plan_id,),
+ ).fetchone()
+ if row is None:
+ connection.rollback()
+ raise PlanTransitionError("plan was not found")
+ status = PlanStatus(row[0])
+ if row[1] is not None and int(row[1]) > now:
+ connection.rollback()
+ raise PlanTransitionError("execution lease is still active")
+ if status is PlanStatus.EXECUTING:
+ connection.execute(
+ """UPDATE write_plans SET status = ?, lease_owner = NULL,
+ lease_expires_at = NULL
+ WHERE plan_id = ? AND status = ? AND
+ (lease_expires_at IS NULL OR lease_expires_at <= ?)""",
+ (
+ PlanStatus.UNKNOWN.value,
+ plan_id,
+ PlanStatus.EXECUTING.value,
+ now,
+ ),
+ )
+ elif status in {
+ PlanStatus.UNKNOWN,
+ PlanStatus.LEGACY_UNKNOWN,
+ PlanStatus.PARTIAL,
+ }:
+ connection.execute(
+ "UPDATE write_plans SET lease_owner = NULL, lease_expires_at = NULL WHERE plan_id = ?",
+ (plan_id,),
+ )
+ connection.commit()
+ plan = self.get_plan(plan_id)
+ if plan is None:
+ raise PlanTransitionError("plan is not canonically rehydratable")
+ return plan
+
+ def record_reconciliation_receipt(self, receipt: ApplyReceipt) -> None:
+ """Persist evidence only after the executor has been fenced."""
+ if not receipt.plan_id or not receipt.operation_id:
+ raise ValueError("canonical receipt requires plan_id and operation_id")
+ self.prepare()
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ plan_row = connection.execute(
+ "SELECT lease_owner FROM write_plans WHERE plan_id = ?",
+ (receipt.plan_id,),
+ ).fetchone()
+ if plan_row is None or plan_row[0] is not None:
+ connection.rollback()
+ raise PlanTransitionError("reconciliation requires a fenced executor")
+ updated = connection.execute(
+ """UPDATE write_operations SET status = ?, receipt_json = ?,
+ updated_at = ?, reconciled = 1
+ WHERE plan_id = ? AND operation_id = ?
+ AND status IN ('ISSUED','EXECUTING','UNKNOWN','LEGACY_UNKNOWN')""",
+ (
+ receipt.status.value,
+ self._canonical_json(receipt.to_dict()),
+ receipt.committed_at or int(time.time()),
+ receipt.plan_id,
+ receipt.operation_id,
+ ),
+ )
+ if updated.rowcount != 1:
+ connection.rollback()
+ raise PlanTransitionError("operation is not reconcilable")
+ statuses = [
+ str(row[0])
+ for row in connection.execute(
+ "SELECT status FROM write_operations WHERE plan_id = ? ORDER BY ordinal",
+ (receipt.plan_id,),
+ )
+ ]
+ aggregate = self._aggregate_persisted_statuses(statuses)
+ connection.execute(
+ "UPDATE write_plans SET status = ? WHERE plan_id = ?",
+ (aggregate.value, receipt.plan_id),
+ )
+ connection.commit()
+
+ @staticmethod
+ def _aggregate_persisted_statuses(statuses: list[str]) -> PlanStatus:
+ completed: list[ApplyStatus] = []
+ pending = False
+ for status in statuses:
+ try:
+ completed.append(ApplyStatus(status))
+ except ValueError:
+ pending = True
+ if pending:
+ if any(status in {ApplyStatus.APPLIED, ApplyStatus.ALREADY_APPLIED} for status in completed):
+ return PlanStatus.PARTIAL
+ if ApplyStatus.UNKNOWN in completed:
+ return PlanStatus.UNKNOWN
+ if ApplyStatus.CONFLICT in completed:
+ return PlanStatus.CONFLICT
+ if ApplyStatus.RETRYABLE_NOT_APPLIED in completed:
+ return PlanStatus.RETRYABLE_NOT_APPLIED
+ if completed and all(status is ApplyStatus.REJECTED for status in completed):
+ return PlanStatus.REJECTED
+ return PlanStatus.EXECUTING
+ return aggregate_plan_status(completed)
+
+ def get_plan_record(self, plan_id: str) -> dict[str, Any] | None:
+ self.prepare()
+ with sqlite3.connect(self.database_path, timeout=30) as connection:
+ connection.row_factory = sqlite3.Row
+ plan = connection.execute(
+ "SELECT * FROM write_plans WHERE plan_id = ?",
+ (plan_id,),
+ ).fetchone()
+ if plan is None:
+ return None
+ operations = connection.execute(
+ """
+ SELECT * FROM write_operations
+ WHERE plan_id = ? ORDER BY ordinal
+ """,
+ (plan_id,),
+ ).fetchall()
+ return {
+ "plan_id": plan["plan_id"],
+ "tool_name": plan["tool_name"],
+ "graph_target": json.loads(plan["graph_target_json"]),
+ "principal": plan["principal"],
+ "payload": json.loads(plan["payload_json"]),
+ "payload_digest": plan["payload_digest"],
+ "schema_fingerprint": plan["schema_fingerprint"],
+ "status": plan["status"],
+ "created_at": plan["created_at"],
+ "expires_at": plan["expires_at"],
+ "confirmed_at": plan["confirmed_at"],
+ "lease_owner": plan["lease_owner"],
+ "lease_expires_at": plan["lease_expires_at"],
+ "operations": [
+ {
+ "operation_id": operation["operation_id"],
+ "ordinal": operation["ordinal"],
+ "kind": operation["kind"],
+ "payload": json.loads(operation["payload_json"]),
+ "status": operation["status"],
+ "attempt": operation["attempt"],
+ "attempt_token": operation["attempt_token"],
+ "reconciled": bool(operation["reconciled"]),
+ "receipt": (json.loads(operation["receipt_json"]) if operation["receipt_json"] else None),
+ "updated_at": operation["updated_at"],
+ }
+ for operation in operations
+ ],
+ }
+
+ def _migrate_schema(self, connection: sqlite3.Connection, version: int) -> None:
+ """Apply additive schema steps in order; never silently downgrade."""
+ if version < 1:
+ self._ensure_v1_schema(connection)
+ connection.execute("PRAGMA user_version = 1")
+ version = 1
+ if version < 2:
+ plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(write_plans)")}
+ operation_columns = {row[1] for row in connection.execute("PRAGMA table_info(write_operations)")}
+ if "lease_owner" not in plan_columns:
+ connection.execute("ALTER TABLE write_plans ADD COLUMN lease_owner TEXT")
+ if "lease_expires_at" not in plan_columns:
+ connection.execute("ALTER TABLE write_plans ADD COLUMN lease_expires_at INTEGER")
+ if "attempt_token" not in operation_columns:
+ connection.execute("ALTER TABLE write_operations ADD COLUMN attempt_token TEXT")
+ connection.execute("PRAGMA user_version = 2")
+ version = 2
+ if version < 3:
+ operation_columns = {row[1] for row in connection.execute("PRAGMA table_info(write_operations)")}
+ if "reconciled" not in operation_columns:
+ connection.execute("ALTER TABLE write_operations ADD COLUMN reconciled INTEGER NOT NULL DEFAULT 0")
+ connection.execute("PRAGMA user_version = 3")
+
+ def _ensure_v1_schema(self, connection: sqlite3.Connection) -> None:
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS write_plans (
+ plan_id TEXT PRIMARY KEY,
+ tool_name TEXT NOT NULL,
+ graph_target_json TEXT NOT NULL,
+ principal TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ payload_digest TEXT NOT NULL,
+ schema_fingerprint TEXT,
+ status TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ confirmed_at INTEGER
+ )
+ """
+ )
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS write_operations (
+ operation_id TEXT PRIMARY KEY,
+ plan_id TEXT NOT NULL,
+ ordinal INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ status TEXT NOT NULL,
+ attempt INTEGER NOT NULL DEFAULT 0,
+ receipt_json TEXT,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(plan_id, ordinal),
+ FOREIGN KEY(plan_id) REFERENCES write_plans(plan_id)
+ )
+ """
+ )
+
+ def _migrate_legacy(self, connection: sqlite3.Connection) -> None:
+ if not self.legacy_database_path.exists():
+ return
+ with sqlite3.connect(self.legacy_database_path, timeout=30) as legacy:
+ legacy.row_factory = sqlite3.Row
+ issued = self._legacy_rows(legacy, "issued_confirmations", "plan_hash")
+ consumed = self._legacy_rows(legacy, "consumed_confirmations", "plan_hash")
+ operations = self._legacy_rows(legacy, "write_operations", "plan_hash")
+
+ for plan_hash in sorted(set(issued) | set(consumed) | set(operations)):
+ self._migrate_legacy_plan(
+ connection,
+ plan_hash=plan_hash,
+ issued=issued.get(plan_hash),
+ consumed=consumed.get(plan_hash),
+ operation=operations.get(plan_hash),
+ )
+
+ def _migrate_legacy_plan(
+ self,
+ connection: sqlite3.Connection,
+ *,
+ plan_hash: str,
+ issued: sqlite3.Row | None,
+ consumed: sqlite3.Row | None,
+ operation: sqlite3.Row | None,
+ ) -> None:
+ payload_text = self._row_value(operation, "plan_payload_json")
+ if payload_text is None:
+ payload_text = self._row_value(issued, "plan_payload_json")
+ payload, payload_valid = self._safe_json(payload_text)
+
+ receipt_text = self._row_value(operation, "receipt_json")
+ receipt, receipt_valid = self._safe_json(receipt_text)
+ status = self._legacy_status(
+ operation=operation,
+ consumed=consumed,
+ payload_valid=payload_valid,
+ receipt_valid=receipt_valid,
+ )
+ if not payload_valid or not receipt_valid:
+ receipt = {"reason_code": "CORRUPT_LEGACY_STATE"}
+
+ created_at = int(
+ self._row_value(issued, "issued_at")
+ or self._row_value(consumed, "consumed_at")
+ or self._row_value(operation, "updated_at")
+ or 0
+ )
+ expires_at = int(self._row_value(issued, "expires_at") or self._row_value(consumed, "expires_at") or created_at)
+ confirmed_at = int(self._row_value(consumed, "consumed_at")) if consumed else None
+ plan_id = self.legacy_plan_id(plan_hash)
+ payload_json = self._canonical_json(payload)
+ receipt_json = self._canonical_json(receipt) if receipt is not None else None
+
+ connection.execute(
+ """
+ INSERT OR IGNORE INTO write_plans (
+ plan_id, tool_name, graph_target_json, principal, payload_json,
+ payload_digest, schema_fingerprint, status, created_at,
+ expires_at, confirmed_at
+ ) VALUES (?, 'legacy', '{}', 'legacy', ?, ?, NULL, ?, ?, ?, ?)
+ """,
+ (
+ plan_id,
+ payload_json,
+ plan_hash,
+ status.value,
+ created_at,
+ expires_at,
+ confirmed_at,
+ ),
+ )
+ connection.execute(
+ """
+ INSERT OR IGNORE INTO write_operations (
+ operation_id, plan_id, ordinal, kind, payload_json, status,
+ attempt, receipt_json, updated_at
+ ) VALUES (?, ?, 0, 'LEGACY_CONFIRMATION', ?, ?, ?, ?, ?)
+ """,
+ (
+ self.legacy_operation_id(plan_hash),
+ plan_id,
+ payload_json,
+ status.value,
+ 1 if consumed else 0,
+ receipt_json,
+ int(self._row_value(operation, "updated_at") or created_at),
+ ),
+ )
+
+ @staticmethod
+ def _legacy_status(
+ *,
+ operation: sqlite3.Row | None,
+ consumed: sqlite3.Row | None,
+ payload_valid: bool,
+ receipt_valid: bool,
+ ) -> PlanStatus:
+ if not payload_valid or not receipt_valid:
+ return PlanStatus.LEGACY_UNKNOWN
+ raw_status = SQLitePlanStore._row_value(operation, "status")
+ if raw_status is not None:
+ try:
+ return PlanStatus(raw_status)
+ except ValueError:
+ return PlanStatus.LEGACY_UNKNOWN
+ if consumed is not None:
+ return PlanStatus.LEGACY_UNKNOWN
+ return PlanStatus.ISSUED
+
+ @staticmethod
+ def _legacy_rows(
+ connection: sqlite3.Connection,
+ table: str,
+ key: str,
+ ) -> dict[str, sqlite3.Row]:
+ exists = connection.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
+ (table,),
+ ).fetchone()
+ if exists is None:
+ return {}
+ return {str(row[key]): row for row in connection.execute(f"SELECT * FROM {table}")}
+
+ @staticmethod
+ def _row_value(row: sqlite3.Row | None, key: str) -> Any:
+ if row is None or key not in set(row.keys()):
+ return None
+ return row[key]
+
+ @staticmethod
+ def _safe_json(value: str | None) -> tuple[Any, bool]:
+ if value is None:
+ return None, True
+ try:
+ return json.loads(value), True
+ except (TypeError, json.JSONDecodeError):
+ return None, False
+
+ @staticmethod
+ def _canonical_json(value: Any) -> str:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+
+ def _prepare_storage(self) -> None:
+ self.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
+ self.state_dir.chmod(0o700)
+ descriptor = os.open(
+ self.database_path,
+ os.O_CREAT | os.O_APPEND | os.O_WRONLY,
+ 0o600,
+ )
+ os.close(descriptor)
+ self.database_path.chmod(0o600)
+
+
+def plan_store_from_config(cfg=None) -> PlanStore:
+ if cfg is None:
+ from hugegraph_mcp.config import MCPConfig
+
+ cfg = MCPConfig.from_env()
+ if not cfg.has_safe_write_store():
+ raise PlanStoreUnavailableError("The configured plan store is not safe for this write topology")
+ return SQLitePlanStore(cfg.state_dir)
diff --git a/hugegraph-mcp/hugegraph_mcp/reconciler.py b/hugegraph-mcp/hugegraph_mcp/reconciler.py
new file mode 100644
index 00000000..05a33579
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/reconciler.py
@@ -0,0 +1,202 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Read-only reconciliation for ambiguous persisted write outcomes."""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Callable, Mapping
+from typing import Any
+
+from hugegraph_mcp.envelope import ErrorType, envelope_err
+from hugegraph_mcp.plan_store import (
+ PlanStore,
+ PlanTransitionError,
+ plan_store_from_config,
+)
+from hugegraph_mcp.write_executor import WriteExecutor, WriteExecutorRegistry
+from hugegraph_mcp.write_plan import (
+ ApplyReceipt,
+ ApplyStatus,
+ OperationPlan,
+ PlanStatus,
+ WritePlan,
+)
+
+ReconcileReader = Callable[[WritePlan, OperationPlan], Mapping[str, Any]]
+
+
+class ReconcileReaderRegistry:
+ def __init__(self) -> None:
+ self._readers: dict[str, ReconcileReader] = {}
+
+ def register(self, kind: str, reader: ReconcileReader) -> None:
+ if not kind:
+ raise ValueError("reader kind must be non-empty")
+ self._readers[kind] = reader
+
+ def reader_for(self, kind: str) -> ReconcileReader | None:
+ return self._readers.get(kind)
+
+
+def reconcile_operation_state(
+ kind: str,
+ expected_state: Mapping[str, Any],
+ desired_state: Mapping[str, Any],
+ observed_state: Mapping[str, Any],
+) -> ApplyStatus:
+ """Classify observed state without performing any write."""
+
+ observed = dict(observed_state)
+ if observed == dict(desired_state):
+ return ApplyStatus.APPLIED
+ if observed == dict(expected_state):
+ return ApplyStatus.RETRYABLE_NOT_APPLIED
+ if kind.startswith("DELETE_") and observed.get("exists") is False:
+ return ApplyStatus.APPLIED
+ return ApplyStatus.CONFLICT
+
+
+class WriteReconciler:
+ def __init__(self, *, store: PlanStore, readers: ReconcileReaderRegistry):
+ self.store = store
+ self.readers = readers
+
+ def reconcile(self, plan_id: str) -> dict:
+ record = self.store.get_plan_record(plan_id)
+ if record is None:
+ return envelope_err(
+ ErrorType.NOT_FOUND,
+ "No persisted write plan matches this plan_id.",
+ retryable=False,
+ )
+ if record["tool_name"] == "legacy":
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "Legacy confirmation state cannot be safely reconciled.",
+ retryable=False,
+ details={"reason_code": "LEGACY_PLAN_NOT_RECONCILABLE"},
+ )
+ try:
+ plan = self.store.begin_reconcile(plan_id)
+ except PlanTransitionError as exc:
+ return envelope_err(
+ ErrorType.WRITE_CONFLICT,
+ "The write plan still has an active execution lease.",
+ retryable=False,
+ details={"reason_code": "EXECUTION_LEASE_ACTIVE", "reason": str(exc)},
+ )
+ if plan.status not in {
+ PlanStatus.UNKNOWN,
+ PlanStatus.LEGACY_UNKNOWN,
+ PlanStatus.PARTIAL,
+ }:
+ return self._status(plan_id)
+
+ record = self.store.get_plan_record(plan_id)
+ assert record is not None
+ operation_records = {item["operation_id"]: item for item in record["operations"]}
+ unresolved = {
+ PlanStatus.EXECUTING.value,
+ PlanStatus.UNKNOWN.value,
+ PlanStatus.LEGACY_UNKNOWN.value,
+ }
+ for operation in plan.operations:
+ operation_record = operation_records[operation.operation_id]
+ if operation_record["status"] == PlanStatus.ISSUED.value:
+ self.store.record_reconciliation_receipt(
+ ApplyReceipt(
+ plan_id=plan_id,
+ operation_id=operation.operation_id,
+ status=ApplyStatus.RETRYABLE_NOT_APPLIED,
+ reason_code="RECONCILED_OPERATION_NOT_CLAIMED",
+ attempt=operation_record["attempt"],
+ reconciliation_required=False,
+ )
+ )
+ continue
+ if operation_record["status"] not in unresolved:
+ continue
+ reader = self.readers.reader_for(operation.kind)
+ if reader is None:
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "The write plan requires an unavailable reconciliation reader.",
+ retryable=False,
+ details={"missing_operation_kind": operation.kind},
+ )
+ observed = reader(plan, operation)
+ status = reconcile_operation_state(
+ operation.kind,
+ operation.expected_state,
+ operation.desired_state,
+ observed,
+ )
+ reason_code = "RECONCILED_CURRENT_STATE"
+ reconciliation_required = False
+ if status is ApplyStatus.RETRYABLE_NOT_APPLIED and operation_record["attempt"] > 0:
+ # A backend read cannot prove that an already-dispatched request
+ # will not commit later. Only never-claimed operations are safe
+ # to authorize for replay.
+ status = ApplyStatus.UNKNOWN
+ reason_code = "IN_FLIGHT_COMMIT_NOT_EXCLUDED"
+ reconciliation_required = True
+ self.store.record_reconciliation_receipt(
+ ApplyReceipt(
+ plan_id=plan_id,
+ operation_id=operation.operation_id,
+ status=status,
+ observed_state=observed,
+ reason_code=reason_code,
+ attempt=operation_record["attempt"],
+ reconciliation_required=reconciliation_required,
+ committed_at=(int(time.time()) if status is ApplyStatus.APPLIED else None),
+ )
+ )
+ return self._status(plan_id)
+
+ def _status(self, plan_id: str) -> dict:
+ return WriteExecutor(
+ store=self.store,
+ registry=WriteExecutorRegistry(),
+ ).status(plan_id)
+
+
+DEFAULT_RECONCILE_READERS = ReconcileReaderRegistry()
+
+from hugegraph_mcp.tools.graph_write_adapter import (
+ register_graph_reconcile_readers,
+)
+from hugegraph_mcp.tools.schema_write_adapter import (
+ register_schema_reconcile_readers,
+)
+
+register_graph_reconcile_readers(DEFAULT_RECONCILE_READERS)
+register_schema_reconcile_readers(DEFAULT_RECONCILE_READERS)
+
+
+def reconcile_write(plan_id: str) -> dict:
+ try:
+ store = plan_store_from_config()
+ except Exception as exc: # noqa: BLE001 - public tool must remain enveloped
+ return envelope_err(
+ ErrorType.SERVER_ERROR,
+ "The durable plan store is unavailable.",
+ retryable=False,
+ details={"reason": type(exc).__name__},
+ )
+ try:
+ return WriteReconciler(
+ store=store,
+ readers=DEFAULT_RECONCILE_READERS,
+ ).reconcile(plan_id)
+ except PlanTransitionError as exc:
+ return envelope_err(
+ ErrorType.WRITE_CONFLICT,
+ "The persisted plan state changed during reconciliation.",
+ retryable=False,
+ details={"reason": str(exc)},
+ )
diff --git a/hugegraph-mcp/hugegraph_mcp/server.py b/hugegraph-mcp/hugegraph_mcp/server.py
index d039da09..157891d9 100644
--- a/hugegraph-mcp/hugegraph_mcp/server.py
+++ b/hugegraph-mcp/hugegraph_mcp/server.py
@@ -18,92 +18,45 @@ server.py 只负责参数校验和 mode 分发,具体业务逻辑委托给 too
"""
import logging
-import logging.handlers
import os
import time
from typing import Any
-# ---- Startup patch: prevent module-level pyhugegraph logging from writing files. ----
-# pyhugegraph creates a RotatingFileHandler under 'logs/' during import. In MCP
-# stdio mode this would corrupt the JSON protocol stream, so intercept makedirs
-# and RotatingFileHandler.
-
-_original_makedirs = os.makedirs
-
-
-def _safe_makedirs(name, mode=0o777, exist_ok=False):
- if _is_logs_dir(name):
- return None
- return _original_makedirs(name, mode, exist_ok)
-
-
-def _is_logs_dir(name) -> bool:
- try:
- path = os.fspath(name)
- except TypeError:
- return False
- return os.path.basename(os.path.normpath(path)).lower() == "logs"
-
-
-_OriginalRotatingFileHandler = logging.handlers.RotatingFileHandler
-
-
-class _NoOpFileHandler(logging.NullHandler):
- """无操作日志处理器 — 用于禁用文件日志记录。"""
-
- def __init__(self, *args, **kwargs):
- super().__init__()
-
-
-def _patched_rotating_handler(filename, *args, **kwargs):
- if _is_logs_file(filename):
- return _NoOpFileHandler()
- return _OriginalRotatingFileHandler(filename, *args, **kwargs)
-
-
-def _is_logs_file(filename) -> bool:
- try:
- path = os.path.normpath(os.fspath(filename))
- except TypeError:
- return False
- return any(part.lower() == "logs" for part in path.split(os.sep))
-
-
-logging.handlers.RotatingFileHandler = _patched_rotating_handler
-
-os.makedirs = _safe_makedirs
-
-try:
- # ---- Safely import pyhugegraph-dependent modules within the patch scope. ----
- from fastmcp import FastMCP
-
- from hugegraph_mcp.config import MCPConfig
- from hugegraph_mcp.envelope import ErrorType, envelope_err
- from hugegraph_mcp.gremlin_tools import execute_gremlin_read, execute_gremlin_write
- from hugegraph_mcp.guard import Capability
- from hugegraph_mcp.tools.extract_graph_data import extract_graph_data
- from hugegraph_mcp.tools.generate_gremlin import generate_gremlin
- from hugegraph_mcp.tools.inspect_graph import inspect_graph
- from hugegraph_mcp.tools.inspect_schema import inspect_schema
- from hugegraph_mcp.tools.manage_graph_data import manage_graph_data
- from hugegraph_mcp.tools.manage_schema import manage_schema
- from hugegraph_mcp.tools.mutate_graph_properties import mutate_graph_properties
- from hugegraph_mcp.tools.query_graph_data import query_graph_data
- from hugegraph_mcp.tools.refresh_vid_embeddings import refresh_vid_embeddings
-finally:
- os.makedirs = _original_makedirs
- logging.handlers.RotatingFileHandler = _OriginalRotatingFileHandler
+from fastmcp import FastMCP
+
+from hugegraph_mcp.backend_capabilities import BackendFeature, profile_for
+from hugegraph_mcp.config import MCPConfig
+from hugegraph_mcp.envelope import ErrorType, envelope_err
+from hugegraph_mcp.gremlin_tools import execute_gremlin_read, execute_gremlin_write
+from hugegraph_mcp.guard import Capability
+from hugegraph_mcp.reconciler import reconcile_write
+from hugegraph_mcp.tools.extract_graph_data import extract_graph_data
+from hugegraph_mcp.tools.generate_gremlin import generate_gremlin
+from hugegraph_mcp.tools.inspect_graph import inspect_graph
+from hugegraph_mcp.tools.inspect_schema import inspect_schema
+from hugegraph_mcp.tools.manage_graph_data import manage_graph_data
+from hugegraph_mcp.tools.manage_schema import manage_schema
+from hugegraph_mcp.tools.mutate_graph_properties import mutate_graph_properties
+from hugegraph_mcp.tools.query_graph_data import query_graph_data
+from hugegraph_mcp.tools.refresh_vid_embeddings import refresh_vid_embeddings
+from hugegraph_mcp.write_contract import resolve_optional_legacy_locator
+from hugegraph_mcp.write_executor import confirm_write, get_write_status
READONLY = MCPConfig.from_env().is_readonly()
MCP_TOOL_CONTRACT_VERSION = "2.0"
DEFAULT_TOOLSET = "v2_core"
+VALID_TOOLSETS = frozenset({"v1", DEFAULT_TOOLSET})
+LOGGER = logging.getLogger("hugegraph_mcp.server")
mcp = FastMCP("HugeGraph MCP")
def _active_toolset() -> str:
value = os.getenv("HUGEGRAPH_MCP_TOOLSET", DEFAULT_TOOLSET).strip()
- return "v1" if value == "v1" else DEFAULT_TOOLSET
+ if value in VALID_TOOLSETS:
+ return value
+ LOGGER.error("Invalid HUGEGRAPH_MCP_TOOLSET value %r; falling back to v1", value)
+ return "v1"
def _register_v2_core_tool(func):
@@ -151,6 +104,47 @@ def _call_public_tool(tool_name: str, func, *args, **kwargs) -> dict[str, Any]:
)
+def _call_legacy_write_tool(
+ tool_name: str,
+ func,
+ *args,
+ plan_hash: str | None,
+ nonce: str | None,
+ expires_at: float | None,
+ **kwargs,
+) -> dict[str, Any]:
+ """Validate the deprecated locator before delegating to a write workflow."""
+
+ start = time.perf_counter()
+ resolution, error = resolve_optional_legacy_locator(
+ plan_hash=plan_hash,
+ nonce=nonce,
+ expires_at=expires_at,
+ )
+ if error is not None:
+ return _align_public_tool_envelope(
+ error,
+ tool_name=tool_name,
+ duration_ms=(time.perf_counter() - start) * 1000.0,
+ )
+
+ result = _call_public_tool(
+ tool_name,
+ func,
+ *args,
+ plan_hash=plan_hash,
+ nonce=nonce,
+ expires_at=expires_at,
+ **kwargs,
+ )
+ if resolution is not None:
+ result["warnings"] = [
+ *(result.get("warnings") or []),
+ *resolution.warnings,
+ ]
+ return result
+
+
def _is_admin_mode_enabled() -> bool:
return MCPConfig.from_env().admin_mode
@@ -164,10 +158,7 @@ def _admin_gate(tool_name: str, *, requires_write: bool = False) -> dict | None:
if requires_write:
enable_env["readonly"] = "HUGEGRAPH_MCP_READONLY"
required_env["HUGEGRAPH_MCP_READONLY"] = "false"
- suggestion = (
- f"Set HUGEGRAPH_MCP_ADMIN_MODE=true and HUGEGRAPH_MCP_READONLY=false "
- f"to enable {tool_name}."
- )
+ suggestion = f"Set HUGEGRAPH_MCP_ADMIN_MODE=true and HUGEGRAPH_MCP_READONLY=false to enable {tool_name}."
return envelope_err(
ErrorType.FEATURE_DISABLED,
f"{tool_name} is an admin/debug tool and is disabled by default.",
@@ -202,6 +193,45 @@ def _admin_gate(tool_name: str, *, requires_write: bool = False) -> dict | None:
return None
+_RAW_GREMLIN_HARD_BUDGET_FEATURES = (
+ BackendFeature.READONLY_PRINCIPAL,
+ BackendFeature.GREMLIN_EVALUATION_TIMEOUT,
+ BackendFeature.REST_GREMLIN_WAIT_TIMEOUT,
+ BackendFeature.GREMLIN_RESULT_ITEM_LIMIT,
+ BackendFeature.HTTP_STREAMING_RESPONSE_LIMIT,
+)
+
+
+def _raw_gremlin_hard_budget_gate(tool_name: str) -> dict | None:
+ """Fail closed until the complete raw-query safety contract is verified.
+
+ The only evidence-backed deployment profile currently supported by this
+ package lacks both a general server result cap and a streaming client byte
+ cap. Post-materialization output guards cannot substitute for either.
+ """
+ profile = profile_for("1.7.0", "rocksdb")
+ evidence = {
+ feature.value: {
+ "status": profile.status_for(feature).value,
+ "reason": profile.evidence_for(feature).reason,
+ }
+ for feature in _RAW_GREMLIN_HARD_BUDGET_FEATURES
+ }
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ f"{tool_name} is disabled because the raw Gremlin hard-budget contract is incomplete.",
+ suggestion="Use structured MCP query tools; generating Gremlin with execute=false remains available.",
+ source=tool_name,
+ details={
+ "tool": tool_name,
+ "required_capabilities": evidence,
+ "integration_contract_verified": False,
+ "post_materialization_limits_are_hard_budgets": False,
+ },
+ duration_ms=0.0,
+ )
+
+
# ========== Tool 1: inspect graph status and schema ==========
@@ -233,6 +263,36 @@ def inspect_graph_tool(
return result
+@_register_v2_core_tool
+def get_write_status_tool(plan_id: str) -> dict:
+ """Return the durable plan, operation, and receipt outcome by plan_id."""
+ return _call_public_tool(
+ "get_write_status_tool",
+ get_write_status,
+ plan_id=plan_id,
+ )
+
+
+@_register_v2_core_tool
+def confirm_write_tool(plan_id: str) -> dict:
+ """Confirm exactly one immutable server-persisted plan by plan_id only."""
+ return _call_public_tool(
+ "confirm_write_tool",
+ confirm_write,
+ plan_id=plan_id,
+ )
+
+
+@_register_v2_core_tool
+def reconcile_write_tool(plan_id: str) -> dict:
+ """Reconcile an UNKNOWN or PARTIAL plan by plan_id using read-only checks."""
+ return _call_public_tool(
+ "reconcile_write_tool",
+ reconcile_write,
+ plan_id=plan_id,
+ )
+
+
@_register_v2_core_tool
def inspect_schema_tool(
include_raw_schema: bool = False,
@@ -309,15 +369,21 @@ def generate_gremlin_tool(
query: str,
execute: bool = False,
output_types: list[str] | None = None,
- limit_policy: str = "warn",
+ limit_policy: str = "reject_unbounded",
) -> dict:
- """V1 稳定工具:自然语言 → Gremlin 生成。
+ """Generate Gremlin without executing it. Capability: GENERATE.
- 默认不执行(execute=false),返回生成的 Gremlin 查询。
- 设置 execute=true 可执行生成的只读 Gremlin。Capability: GENERATE.
- limit_policy 仅在 execute=true 时传给只读执行:warn、reject_unbounded、
- auto_append。auto_append 会返回 original_gremlin/executed_gremlin/rewrite_reason。
+ Every public raw Gremlin execution path is disabled until the hard resource
+ budget contract is verified, so execute=true currently returns
+ FEATURE_DISABLED. limit_policy remains as a compatibility parameter.
"""
+ if execute:
+ blocked = _raw_gremlin_hard_budget_gate("generate_gremlin_tool(execute=true)")
+ if blocked:
+ return blocked
+ blocked = _admin_gate("generate_gremlin_tool(execute=true)")
+ if blocked:
+ return blocked
return _call_public_tool(
"generate_gremlin_tool",
generate_gremlin,
@@ -329,17 +395,19 @@ def generate_gremlin_tool(
@mcp.tool()
-def execute_gremlin_read_tool(gremlin_query: str, limit_policy: str = "warn") -> dict:
- """V1 稳定工具:执行只读 Gremlin 遍历查询。
+def execute_gremlin_read_tool(gremlin_query: str, limit_policy: str = "reject_unbounded") -> dict:
+ """Registered compatibility tool; raw read execution is currently disabled.
- Capability: READ.
- 经过 GremlinPolicy 安全检查后执行。
- limit_policy values:
- - warn: execute and return cost warnings for unbounded reads.
- - reject_unbounded: reject safe but unbounded reads before execution.
- - auto_append: append .limit(100) to simple unbounded g.V()/g.E() queries and
- return original_gremlin/executed_gremlin/rewrite_reason.
+ Public raw Gremlin remains FEATURE_DISABLED until the deployment proves the
+ complete server timeout/result cap, streaming byte cap, and readonly-principal
+ contract. Use query_graph_data_tool for executable bounded reads.
"""
+ blocked = _raw_gremlin_hard_budget_gate("execute_gremlin_read_tool")
+ if blocked:
+ return blocked
+ blocked = _admin_gate("execute_gremlin_read_tool")
+ if blocked:
+ return blocked
return _call_public_tool(
"execute_gremlin_read_tool",
execute_gremlin_read,
@@ -398,9 +466,10 @@ def apply_schema_tool(
mode values:
- design: schema design guidance; operations optional.
- validate: validate operations against live schema.
- - dry_run: validate P0a create operations and return plan_hash/nonce/expires_at.
- - apply: P0a only. Requires confirm=true, plan_hash, nonce, expires_at from
- dry_run. Supports create_property_key, create_vertex_label, create_edge_label.
+ - dry_run: validate exactly one P0a create operation and issue a persisted plan.
+ - apply: deprecated compatibility confirmation for one create operation.
+ New clients confirm the returned plan_id with confirm_write_tool.
+ Supports create_property_key, create_vertex_label, create_edge_label.
Rejects create_index_label, schema append/eliminate, remove/drop.
"""
start = time.perf_counter()
@@ -413,7 +482,7 @@ def apply_schema_tool(
details={"mode": mode, "tool": "apply_schema_tool"},
duration_ms=(time.perf_counter() - start) * 1000.0,
)
- return _call_public_tool(
+ return _call_legacy_write_tool(
"apply_schema_tool",
manage_schema,
mode=mode,
@@ -441,13 +510,11 @@ def mutate_graph_properties_tool(
Capability: DATA_WRITE.
target values: vertex, edge.
- operation values: append, eliminate. Both require the same chain:
- dry_run=true -> review before/after and plan_hash -> dry_run=false,
- confirm=true with plan_hash, nonce, and expires_at.
- The confirm step re-reads schema and target; changed targets return
- TARGET_CHANGED and are not mutated.
+ operation values: append, eliminate. Dry-run returns a before/after preview
+ with confirmable=false. Confirmed mutation returns FEATURE_DISABLED because
+ HugeGraph 1.7.0 lacks a backend-enforced atomic compare-and-set update.
"""
- return _call_public_tool(
+ return _call_legacy_write_tool(
"mutate_graph_properties_tool",
mutate_graph_properties,
target=target,
@@ -478,11 +545,11 @@ def import_graph_data_tool(
nonce: str | None = None,
expires_at: float | None = None,
) -> dict:
- """V1 图数据导入入口。
+ """Extract or plan structured graph data import.
- mode="extract": 自然语言文本 → 候选 graph_data
- mode="ingest": MCP 本地校验+dry_run/confirm+Gremlin 导入 graph_data
- mode="table": 当前 MCP contract 不支持(返回 FEATURE_DISABLED)
+ mode="extract" returns candidate graph_data without writing.
+ mode="ingest" validates locally and issues an immutable plan_id; confirm it
+ with confirm_write_tool. mode="table" returns FEATURE_DISABLED.
"""
start = time.perf_counter()
@@ -510,7 +577,7 @@ def import_graph_data_tool(
source="import_graph_data_tool",
duration_ms=(time.perf_counter() - start) * 1000.0,
)
- return _call_public_tool(
+ return _call_legacy_write_tool(
"import_graph_data_tool",
manage_graph_data,
mode="import",
@@ -557,12 +624,14 @@ def delete_graph_data_tool(
nonce: str | None = None,
expires_at: float | None = None,
) -> dict:
- """V1 稳定工具:受控删除图数据。
+ """Preview exact delete_vertex or delete_edge operations.
- 只支持精确 delete_vertex/delete_edge change_plan。
- 必须经过 dry_run -> plan_hash -> confirm;不支持批量条件删除或级联删除。
+ An exact edge delete can be confirmed by plan_id after dry-run. Isolated
+ vertex deletion is preview-only and confirmation returns FEATURE_DISABLED
+ because the backend lacks an atomic no-incident-edge condition. Bulk
+ conditional and cascade deletion are unsupported.
"""
- return _call_public_tool(
+ return _call_legacy_write_tool(
"delete_graph_data_tool",
manage_graph_data,
mode="delete",
@@ -594,11 +663,13 @@ def refresh_vid_embeddings_tool(confirm: bool = False) -> dict:
@mcp.tool()
def execute_gremlin_write_tool(gremlin_query: str) -> dict:
- """Break-glass direct Gremlin write for an isolated trusted admin transport.
+ """Disabled direct Gremlin write pending a verified hard-budget contract.
- This is the sole write-safety-chain exception: it has no preview, dry-run,
- plan hash, or confirmation step. It requires admin mode and readonly=false.
+ Admin mode and readonly=false remain necessary but are not sufficient.
"""
+ blocked = _raw_gremlin_hard_budget_gate("execute_gremlin_write_tool")
+ if blocked:
+ return blocked
blocked = _admin_gate("execute_gremlin_write_tool", requires_write=True)
if blocked:
return blocked
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py
index 9feb4e71..483d71b9 100644
--- a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py
+++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py
@@ -11,14 +11,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Graph data change plan execution.
+"""Graph data change plan compilation and execution.
-Dry-run preview, write execution, pre-execution matched_count verification,
-and plan hash computation.
+Dry-run resolves delete predicates to immutable backend IDs. Execution consumes
+only those compiled IDs, preventing predicate drift between preview and apply.
"""
import hashlib
import json
+from copy import deepcopy
from typing import Any
from hugegraph_mcp import gremlin_tools
@@ -26,6 +27,7 @@ from hugegraph_mcp.config import MCPConfig
from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok
from hugegraph_mcp.guard import Capability
from hugegraph_mcp.tools.graph_data_gremlin import (
+ _delete_target_query,
_edge_match_query,
_g,
_source_vertex_match_query,
@@ -50,6 +52,7 @@ from hugegraph_mcp.write_limits import (
collect_write_limit_errors,
operation_count_from_list,
)
+from hugegraph_mcp.write_plan import ApplyStatus, aggregate_plan_status
# ---- Gremlin execution helpers ----
@@ -103,9 +106,15 @@ def _read_values(gremlin_query: str) -> dict[str, Any]:
retryable=True,
)
data = result.get("data") if isinstance(result, dict) else result
- if isinstance(data, dict) and "data" in data:
+ while isinstance(data, dict) and "data" in data:
data = data["data"]
- return envelope_ok({"values": data if isinstance(data, list) else [data]})
+ if data is None:
+ values = []
+ elif isinstance(data, list):
+ values = data
+ else:
+ values = [data]
+ return envelope_ok({"values": values})
def _mutation_summary(operations: list[dict[str, Any]]) -> dict[str, int]:
@@ -156,8 +165,8 @@ def dry_run_graph_change_plan(
) -> dict[str, Any]:
"""干跑 — 校验 + 预览每个操作的影响(matched_count),不执行写入。
- delete 操作通过只读 Gremlin 查询验证 matched_count==1,
- delete_vertex cascade=false 时检查关联边。
+ delete 操作通过一次有界 ID 查询解析唯一后端目标,
+ 并将 target_id 写入仅供服务端执行的 compiled_plan。
"""
operations = _operations(change_plan)
limit_errors = collect_write_limit_errors(
@@ -175,6 +184,8 @@ def dry_run_graph_change_plan(
return validation
preview: list[dict[str, Any]] = []
errors: list[ValidationError] = []
+ compiled_plan = deepcopy(change_plan)
+ compiled_operations = _operations(compiled_plan)
for idx, operation in enumerate(operations):
op = str(operation.get("op") or operation.get("type"))
@@ -191,7 +202,8 @@ def dry_run_graph_change_plan(
op=op,
item=item,
errors=errors,
- planned_operations=operations,
+ planned_operations=operations[:idx],
+ compiled_operation=compiled_operations[idx],
)
preview.append(item)
if endpoint_failed:
@@ -234,23 +246,20 @@ def dry_run_graph_change_plan(
preview.append(item)
continue
- match_query = (
- _edge_match_query(operation)
- if op == "delete_edge"
- else _vertex_match_query(operation)
- )
- count_result = _read_count(match_query)
- if not count_result.get("ok"):
+ match_query = _edge_match_query(operation) if op == "delete_edge" else _vertex_match_query(operation)
+ id_result = _read_values(f"{match_query}.limit(2).id()")
+ if not id_result.get("ok"):
errors.append(
_validation_error(
idx,
operation,
- "matched_count query failed",
+ "target id query failed",
"Verify HugeGraph Server is available and retry the dry run.",
)
)
continue
- matched_count = count_result["data"]["matched_count"]
+ target_ids = id_result["data"]["values"]
+ matched_count = len(target_ids)
item["matched_count"] = matched_count
if op in {"delete_vertex", "delete_edge"} and matched_count != 1:
@@ -263,15 +272,16 @@ def dry_run_graph_change_plan(
)
)
- if (
- op == "delete_vertex"
- and operation.get("cascade", False) is False
- and matched_count == 1
- ):
+ target_id = target_ids[0] if matched_count == 1 else None
+ if target_id is not None:
+ item["target_id"] = target_id
+ compiled_operations[idx]["target_id"] = target_id
+
+ if op == "delete_vertex" and operation.get("cascade", False) is False and matched_count == 1:
# By default, refuse to delete vertices with incident edges to avoid
# unpredictable data loss from HugeGraph-side cascading behavior.
# Users must explicitly delete the incident edges first.
- edge_count_result = _read_count(f"{match_query}.bothE()")
+ edge_count_result = _read_count(f"g.V({_g(target_id)}).bothE()")
if not edge_count_result.get("ok"):
errors.append(
_validation_error(
@@ -295,7 +305,7 @@ def dry_run_graph_change_plan(
)
)
elif op == "delete_vertex" and operation.get("cascade", False) is True:
- edge_result = _read_values(f"{match_query}.bothE().elementMap()")
+ edge_result = _read_values(f"g.V({_g(target_id)}).bothE().elementMap()")
if not edge_result.get("ok"):
errors.append(
_validation_error(
@@ -333,10 +343,11 @@ def dry_run_graph_change_plan(
return {
"valid": True,
"plan_hash": calculate_graph_change_plan_hash(
- change_plan,
+ compiled_plan,
schema_summary=normalized_schema_summary(live_schema),
extra_hash_context=extra_hash_context,
),
+ "compiled_plan": compiled_plan,
"mutation_summary": _mutation_summary(operations),
"preview": preview,
"warnings": validation.get("warnings", []),
@@ -351,30 +362,65 @@ def _append_edge_endpoint_counts(
item: dict[str, Any],
errors: list[ValidationError],
planned_operations: list[Any] | None = None,
+ compiled_operation: dict[str, Any] | None = None,
) -> bool:
endpoint_failed = False
for endpoint, endpoint_query in (
("source", _source_vertex_match_query(operation)),
("target", _target_vertex_match_query(operation)),
):
- planned_count = _planned_endpoint_match_count(
+ if compiled_operation is None:
+ endpoint_count_result = _read_count(endpoint_query)
+ if not endpoint_count_result.get("ok"):
+ errors.append(
+ _validation_error(
+ idx,
+ operation,
+ f"{endpoint} endpoint count query failed",
+ "Verify HugeGraph Server is available and retry the dry run.",
+ )
+ )
+ endpoint_failed = True
+ continue
+ endpoint_count = endpoint_count_result["data"]["matched_count"]
+ item[f"{endpoint}_matched_count"] = endpoint_count
+ if endpoint_count != 1:
+ errors.append(
+ _validation_error(
+ idx,
+ operation,
+ f"{op} {endpoint} endpoint matched_count must be 1, got {endpoint_count}",
+ "Narrow the endpoint match criteria so exactly one vertex is selected.",
+ )
+ )
+ endpoint_failed = True
+ continue
+
+ planned_ids, _has_unbound_planned_match = _planned_endpoint_ids(
operation=operation,
endpoint=endpoint,
planned_operations=planned_operations,
)
- endpoint_count_result = _read_count(endpoint_query)
- if not endpoint_count_result.get("ok"):
+ planned_indexes = _planned_endpoint_indexes(
+ operation=operation,
+ endpoint=endpoint,
+ planned_operations=planned_operations,
+ )
+ endpoint_id_result = _read_values(f"{endpoint_query}.limit(2).id()")
+ if not endpoint_id_result.get("ok"):
errors.append(
_validation_error(
idx,
operation,
- f"{endpoint} endpoint count query failed",
+ f"{endpoint} endpoint id query failed",
"Verify HugeGraph Server is available and retry the dry run.",
)
)
endpoint_failed = True
continue
- live_count = endpoint_count_result["data"]["matched_count"]
+ live_ids = endpoint_id_result["data"]["values"]
+ planned_count = len(planned_indexes)
+ live_count = len(live_ids)
total_count = planned_count + live_count
item[f"{endpoint}_planned_count"] = planned_count
item[f"{endpoint}_live_count"] = live_count
@@ -389,17 +435,33 @@ def _append_edge_endpoint_counts(
)
)
endpoint_failed = True
+ continue
+
+ if planned_indexes:
+ dependency_index = planned_indexes[0]
+ item[f"{endpoint}_operation_index"] = dependency_index
+ compiled_operation[f"{endpoint}_operation_index"] = dependency_index
+ if planned_ids:
+ # Keep the explicit ID for the one-release legacy executor. The
+ # canonical executor still consumes the dependency receipt.
+ endpoint_id = planned_ids[0]
+ item[f"{endpoint}_id"] = endpoint_id
+ compiled_operation[f"{endpoint}_id"] = endpoint_id
+ else:
+ endpoint_id = live_ids[0]
+ item[f"{endpoint}_id"] = endpoint_id
+ compiled_operation[f"{endpoint}_id"] = endpoint_id
return endpoint_failed
-def _planned_endpoint_match_count(
+def _planned_endpoint_ids(
*,
operation: dict[str, Any],
endpoint: str,
planned_operations: list[Any] | None,
-) -> int:
+) -> tuple[list[Any], bool]:
if not planned_operations:
- return 0
+ return [], False
if endpoint == "source":
label = operation.get("source_label") or operation.get("outVLabel")
@@ -409,9 +471,10 @@ def _planned_endpoint_match_count(
match = operation.get("target_match")
if not isinstance(label, str) or not isinstance(match, dict):
- return 0
+ return [], False
- count = 0
+ stable_ids: list[Any] = []
+ has_unbound_match = False
for planned in planned_operations:
if not isinstance(planned, dict):
continue
@@ -419,8 +482,38 @@ def _planned_endpoint_match_count(
if planned_op != "create_vertex" or planned.get("label") != label:
continue
if _planned_vertex_matches(planned, match):
- count += 1
- return count
+ planned_id = planned.get("id")
+ if planned_id is None or planned_id == "":
+ has_unbound_match = True
+ else:
+ stable_ids.append(planned_id)
+ return stable_ids, has_unbound_match
+
+
+def _planned_endpoint_indexes(
+ *,
+ operation: dict[str, Any],
+ endpoint: str,
+ planned_operations: list[Any] | None,
+) -> list[int]:
+ if not planned_operations:
+ return []
+ if endpoint == "source":
+ label = operation.get("source_label") or operation.get("outVLabel")
+ match = operation.get("source_match")
+ else:
+ label = operation.get("target_label") or operation.get("inVLabel")
+ match = operation.get("target_match")
+ if not isinstance(label, str) or not isinstance(match, dict):
+ return []
+ return [
+ index
+ for index, planned in enumerate(planned_operations)
+ if isinstance(planned, dict)
+ and str(planned.get("op") or planned.get("type") or "") == "create_vertex"
+ and planned.get("label") == label
+ and _planned_vertex_matches(planned, match)
+ ]
def _planned_vertex_matches(operation: dict[str, Any], match: dict[str, Any]) -> bool:
@@ -442,9 +535,10 @@ def execute_graph_change_plan(
change_plan: Any,
live_schema: dict[str, Any] | None = None,
) -> dict[str, Any]:
- """执行变更计划 — 写入前对每个操作再次校验 matched_count。
+ """执行已编译的变更计划。
- 防止 dry_run 和 execute 之间状态变化导致的误操作。
+ delete 操作必须由 dry-run 编译出 target_id,执行阶段不再使用
+ 可变的属性谓词重新定位目标。
"""
operations = _operations(change_plan)
results: list[dict[str, Any]] = []
@@ -460,105 +554,73 @@ def execute_graph_change_plan(
return _execution_failure(conflict, operation, idx, results)
if op == "create_edge":
- for endpoint, endpoint_query in (
- ("source", _source_vertex_match_query(operation)),
- ("target", _target_vertex_match_query(operation)),
- ):
- endpoint_count_result = _read_count(endpoint_query)
- if not endpoint_count_result.get("ok"):
- return _execution_failure(
- endpoint_count_result, operation, idx, results
- )
- endpoint_count = endpoint_count_result["data"]["matched_count"]
- if endpoint_count != 1:
- return _execution_failure(
- envelope_err(
- ErrorType.INVALID_GRAPH_DATA,
- f"{op} {endpoint} endpoint matched_count must be 1 before execution.",
- details={
- "operation_index": idx,
- "matched_count": endpoint_count,
- },
- ),
- operation,
- idx,
- results,
- )
+ missing_ids = [
+ endpoint
+ for endpoint in ("source", "target")
+ if operation.get(f"{endpoint}_id") is None or operation.get(f"{endpoint}_id") == ""
+ ]
+ if missing_ids:
+ return _execution_failure(
+ envelope_err(
+ ErrorType.INVALID_GRAPH_DATA,
+ "create_edge requires dry-run compiled endpoint IDs.",
+ suggestion="Run dry_run again and execute the server-issued compiled plan.",
+ details={
+ "operation_index": idx,
+ "missing_endpoint_ids": missing_ids,
+ },
+ ),
+ operation,
+ idx,
+ results,
+ )
if op in WRITE_OPS:
- # Read matched_count again before execution to handle graph changes
- # between dry-run and confirmation. If the match is no longer unique,
- # refuse the write to avoid TOCTOU issues.
- if op == "delete_edge":
- for endpoint, endpoint_query in (
- ("source", _source_vertex_match_query(operation)),
- ("target", _target_vertex_match_query(operation)),
- ):
- endpoint_count_result = _read_count(endpoint_query)
- if not endpoint_count_result.get("ok"):
- return _execution_failure(
- endpoint_count_result, operation, idx, results
- )
- endpoint_count = endpoint_count_result["data"]["matched_count"]
- if endpoint_count != 1:
- return _execution_failure(
- envelope_err(
- ErrorType.INVALID_GRAPH_DATA,
- f"{op} {endpoint} endpoint matched_count must be 1 before execution.",
- details={
- "operation_index": idx,
- "matched_count": endpoint_count,
- },
- ),
- operation,
- idx,
- results,
- )
- match_query = (
- _edge_match_query(operation)
- if op == "delete_edge"
- else _vertex_match_query(operation)
- )
- count_result = _read_count(match_query)
- if not count_result.get("ok"):
- return _execution_failure(count_result, operation, idx, results)
- matched_count = count_result["data"]["matched_count"]
- if matched_count != 1:
+ target_id = operation.get("target_id")
+ if target_id is None or target_id == "":
return _execution_failure(
envelope_err(
ErrorType.INVALID_GRAPH_DATA,
- f"{op} matched_count must be 1 before execution.",
+ f"{op} requires a dry-run compiled target_id.",
+ suggestion="Run dry_run again and execute the server-issued compiled plan.",
+ details={"operation_index": idx},
+ ),
+ operation,
+ idx,
+ results,
+ )
+ target_query = _delete_target_query(operation)
+ exists_result = _read_count(target_query)
+ if not exists_result.get("ok"):
+ return _execution_failure(exists_result, operation, idx, results)
+ existing_count = exists_result["data"]["matched_count"]
+ if existing_count == 0:
+ results.append(
+ {
+ "operation_index": idx,
+ "op": op,
+ "label": operation.get("label"),
+ "target_id": target_id,
+ "status": ApplyStatus.ALREADY_APPLIED.value,
+ "result": None,
+ }
+ )
+ continue
+ if existing_count != 1:
+ return _execution_failure(
+ envelope_err(
+ ErrorType.WRITE_CONFLICT,
+ f"{op} compiled target_id resolved to an invalid count.",
details={
"operation_index": idx,
- "matched_count": matched_count,
+ "target_id": target_id,
+ "matched_count": existing_count,
},
),
operation,
idx,
results,
)
- if op == "delete_vertex" and operation.get("cascade", False) is False:
- edge_count_result = _read_count(f"{match_query}.bothE()")
- if not edge_count_result.get("ok"):
- return _execution_failure(
- edge_count_result, operation, idx, results
- )
- edge_count = edge_count_result["data"]["matched_count"]
- if edge_count > 0:
- return _execution_failure(
- envelope_err(
- "BLOCKED_BY_RELATIONSHIPS",
- "delete_vertex cascade=false but vertex has associated edges.",
- suggestion="Delete associated edges first, then retry the vertex delete.",
- details={
- "operation_index": idx,
- "associated_edge_count": edge_count,
- },
- ),
- operation,
- idx,
- results,
- )
if op == "delete_vertex" and operation.get("cascade", False) is True:
return _execution_failure(
envelope_err(
@@ -576,8 +638,34 @@ def execute_graph_change_plan(
capability=Capability.DATA_WRITE,
)
if isinstance(write_result, dict) and write_result.get("ok") is False:
+ if write_result.get("error", {}).get("type") in {
+ ErrorType.CONNECTION_FAILED.value,
+ ErrorType.SERVER_ERROR.value,
+ ErrorType.TIMEOUT.value,
+ }:
+ write_result = _unknown_write_outcome(
+ operation=operation,
+ operation_index=idx,
+ cause=write_result,
+ )
return _execution_failure(write_result, operation, idx, results)
if isinstance(write_result, dict) and write_result.get("success") is False:
+ legacy_error_type = str(write_result.get("error_type", "")).lower()
+ if legacy_error_type in {
+ "connection_error",
+ "server_error",
+ "timeout_error",
+ }:
+ return _execution_failure(
+ _unknown_write_outcome(
+ operation=operation,
+ operation_index=idx,
+ cause=write_result,
+ ),
+ operation,
+ idx,
+ results,
+ )
return _execution_failure(
envelope_err(
ErrorType.CONNECTION_FAILED,
@@ -592,15 +680,30 @@ def execute_graph_change_plan(
if op in {"create_vertex", "create_edge"}:
affected = _write_affected_count(write_result)
if affected != 1:
+ if affected is not None:
+ return _execution_failure(
+ envelope_err(
+ ErrorType.FLOW_EXECUTION_FAILED,
+ f"{op} execution affected {affected} element(s), expected 1.",
+ retryable=False,
+ details={
+ "status": ApplyStatus.REJECTED.value,
+ "operation_index": idx,
+ "op": op,
+ "affected": affected,
+ },
+ ),
+ operation,
+ idx,
+ results,
+ )
return _execution_failure(
- envelope_err(
- ErrorType.FLOW_EXECUTION_FAILED,
- f"{op} execution affected {affected if affected is not None else 'unknown'} element(s), expected 1.",
- details={
- "operation_index": idx,
- "op": op,
+ _unknown_write_outcome(
+ operation=operation,
+ operation_index=idx,
+ cause={
+ "reason": "unexpected affected count",
"affected": affected,
- "write_result": write_result,
},
),
operation,
@@ -611,15 +714,55 @@ def execute_graph_change_plan(
# Recheck immediately after deletion to ensure HugeGraph actually
# removed the target. This catches silent backend failures and async
# state anomalies instead of trusting only the write response.
- verify_query = (
- _edge_match_query(operation)
- if op == "delete_edge"
- else _vertex_match_query(operation)
- )
+ verify_query = _delete_target_query(operation)
verify_result = _read_count(verify_query)
if not verify_result.get("ok"):
- return _execution_failure(verify_result, operation, idx, results)
+ return _execution_failure(
+ _unknown_write_outcome(
+ operation=operation,
+ operation_index=idx,
+ cause=verify_result,
+ ),
+ operation,
+ idx,
+ results,
+ )
if verify_result["data"]["matched_count"] != 0:
+ if op == "delete_vertex" and operation.get("cascade", False) is False:
+ edge_count_result = _read_count(f"{verify_query}.bothE()")
+ if not edge_count_result.get("ok"):
+ return _execution_failure(edge_count_result, operation, idx, results)
+ edge_count = edge_count_result["data"]["matched_count"]
+ if edge_count > 0:
+ return _execution_failure(
+ envelope_err(
+ "BLOCKED_BY_RELATIONSHIPS",
+ "delete_vertex cascade=false was blocked by associated edges.",
+ suggestion="Delete associated edges first, then retry the vertex delete.",
+ details={
+ "operation_index": idx,
+ "target_id": operation["target_id"],
+ "associated_edge_count": edge_count,
+ },
+ ),
+ operation,
+ idx,
+ results,
+ )
+ return _execution_failure(
+ envelope_err(
+ ErrorType.WRITE_CONFLICT,
+ "delete_vertex conditional deletion did not remove the target.",
+ suggestion="Inspect the target state and create a new dry-run plan.",
+ details={
+ "operation_index": idx,
+ "target_id": operation["target_id"],
+ },
+ ),
+ operation,
+ idx,
+ results,
+ )
return _execution_failure(
envelope_err(
"DELETE_VERIFY_FAILED",
@@ -640,16 +783,40 @@ def execute_graph_change_plan(
"operation_index": idx,
"op": op,
"label": operation.get("label"),
+ "status": ApplyStatus.APPLIED.value,
"result": write_result,
}
)
+ status = aggregate_plan_status([_result_apply_status(result) for result in results])
return {
"success": True,
+ "status": status.value,
"results": results,
"mutation_summary": _mutation_summary(operations),
}
+def _unknown_write_outcome(
+ *,
+ operation: dict[str, Any],
+ operation_index: int,
+ cause: Any,
+) -> dict[str, Any]:
+ return envelope_err(
+ ErrorType.WRITE_OUTCOME_UNKNOWN,
+ "The write may have committed; reconcile the target state before retrying.",
+ suggestion="Inspect the target by stable identity and do not replay this operation blindly.",
+ retryable=False,
+ details={
+ "status": ApplyStatus.UNKNOWN.value,
+ "reconciliation_required": True,
+ "operation_index": operation_index,
+ "operation": operation,
+ "cause": cause,
+ },
+ )
+
+
def _write_affected_count(write_result: Any) -> int | None:
data = write_result
if isinstance(write_result, dict) and "ok" in write_result:
@@ -748,14 +915,9 @@ def _create_vertex_identity_queries(
if (
primary_keys
and isinstance(properties, dict)
- and all(
- pk in properties and properties.get(pk) not in (None, "")
- for pk in primary_keys
- )
+ and all(pk in properties and properties.get(pk) not in (None, "") for pk in primary_keys)
):
- query = f"g.V().hasLabel({_g(label)})" + "".join(
- f".has({_g(pk)},{_g(properties[pk])})" for pk in primary_keys
- )
+ query = f"g.V().hasLabel({_g(label)})" + "".join(f".has({_g(pk)},{_g(properties[pk])})" for pk in primary_keys)
queries.append(("primary_key", query))
return queries
@@ -784,15 +946,23 @@ def _execution_failure(
return error_result
error = _extract_execution_error(error_result)
+ operation_status = _error_apply_status(error)
+ plan_status = aggregate_plan_status(
+ [
+ *(_result_apply_status(result) for result in results),
+ operation_status,
+ ]
+ )
return {
"success": False,
- "status": "partial",
+ "status": plan_status.value,
"results": results,
"failed_items": [
{
"operation_index": operation_index,
"op": operation.get("op") or operation.get("type"),
"label": operation.get("label"),
+ "status": operation_status.value,
"error": error,
}
],
@@ -801,6 +971,29 @@ def _execution_failure(
}
+def _result_apply_status(result: dict[str, Any]) -> ApplyStatus:
+ """Return the canonical operation status from an executor result."""
+
+ try:
+ return ApplyStatus(result.get("status", ApplyStatus.APPLIED.value))
+ except ValueError:
+ return ApplyStatus.UNKNOWN
+
+
+def _error_apply_status(error: dict[str, Any]) -> ApplyStatus:
+ """Classify a failed operation from the evidence carried by its error."""
+
+ error_type = error.get("type")
+ if error_type == ErrorType.WRITE_OUTCOME_UNKNOWN.value:
+ return ApplyStatus.UNKNOWN
+ if error_type in {
+ ErrorType.WRITE_CONFLICT.value,
+ "BLOCKED_BY_RELATIONSHIPS",
+ }:
+ return ApplyStatus.CONFLICT
+ return ApplyStatus.REJECTED
+
+
def _extract_execution_error(error_result: dict[str, Any]) -> dict[str, Any]:
if isinstance(error_result, dict) and isinstance(error_result.get("error"), dict):
return error_result["error"]
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py
index a5e364e4..dbc3a40f 100644
--- a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py
+++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py
@@ -107,11 +107,9 @@ def _create_vertex_query(operation: dict[str, Any]) -> str:
def _create_edge_query(operation: dict[str, Any]) -> str:
- source_label = operation.get("source_label") or operation.get("outVLabel")
- target_label = operation.get("target_label") or operation.get("inVLabel")
query = (
- f"g.V().hasLabel({_g(source_label)}){_has_steps(operation['source_match'])}.as('s')"
- f".V().hasLabel({_g(target_label)}){_has_steps(operation['target_match'])}"
+ f"g.V({_g(operation['source_id'])}).as('s')"
+ f".V({_g(operation['target_id'])})"
f".addE({_g(operation['label'])}).from('s')"
)
for prop, value in (operation.get("properties") or {}).items():
@@ -120,11 +118,26 @@ def _create_edge_query(operation: dict[str, Any]) -> str:
def _delete_vertex_query(operation: dict[str, Any]) -> str:
- return f"{_vertex_match_query(operation)}.drop()"
+ query = _delete_target_query(operation)
+ if operation.get("cascade", False) is False:
+ # The no-incident-edge precondition and drop execute in one Gremlin
+ # request instead of a client-side check-then-act sequence.
+ query += ".not(bothE())"
+ return f"{query}.drop()"
def _delete_edge_query(operation: dict[str, Any]) -> str:
- return f"{_edge_match_query(operation)}.drop()"
+ return f"{_delete_target_query(operation)}.drop()"
+
+
+def _delete_target_query(operation: dict[str, Any]) -> str:
+ op = str(operation.get("op") or operation.get("type"))
+ target_id = operation["target_id"]
+ if op == "delete_vertex":
+ return f"g.V({_g(target_id)})"
+ if op == "delete_edge":
+ return f"g.E({_g(target_id)})"
+ raise ValueError(f"Unsupported delete op: {op}")
def _write_query(operation: dict[str, Any]) -> str:
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py
index 9be4b608..88a6a87a 100644
--- a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py
+++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py
@@ -19,6 +19,7 @@ Schema checks, property field validation, primary key match verification.
from typing import Any
from hugegraph_mcp.tools.graph_data_mapping import GraphChangePlan
+from hugegraph_mcp.tools.property_validation import property_specs, property_value_error
from hugegraph_mcp.tools.schema_utils import (
edge_schema_endpoint_label as _edge_schema_endpoint_label,
)
@@ -162,6 +163,38 @@ def _validate_field_map(
)
+def _validate_property_values(
+ *,
+ idx: int,
+ operation: dict[str, Any],
+ item_kind: str,
+ specs: dict[str, tuple[str, str]],
+ errors: list[ValidationError],
+) -> None:
+ """Validate create-operation values with the shared schema validator."""
+
+ values = operation.get("properties")
+ if not isinstance(values, dict):
+ return
+ for name, value in values.items():
+ value_error = property_value_error(
+ item_kind=item_kind,
+ item_index=idx,
+ property_name=name,
+ value=value,
+ spec=specs.get(name),
+ )
+ if value_error is not None:
+ errors.append(
+ _validation_error(
+ idx,
+ operation,
+ value_error,
+ "Use a JSON value compatible with the live HugeGraph property schema.",
+ )
+ )
+
+
def _validate_primary_key_match(
*,
idx: int,
@@ -183,9 +216,7 @@ def _validate_primary_key_match(
return
if match.get("id") not in (None, ""):
return
- missing = [
- pk for pk in primary_keys if pk not in match or match.get(pk) in (None, "")
- ]
+ missing = [pk for pk in primary_keys if pk not in match or match.get(pk) in (None, "")]
if missing:
errors.append(
_validation_error(
@@ -206,7 +237,7 @@ def validate_graph_change_plan(
) -> dict[str, Any]:
"""校验 change_plan 中的操作是否与 live schema 兼容。
- 检查项:op 类型白名单、label 存在性、properties/match 字段合法性、
+ 检查项: op 类型白名单、label 存在性、properties/match 字段合法性、
主键匹配、边端点合法性。
"""
errors: list[ValidationError] = []
@@ -226,9 +257,7 @@ def validate_graph_change_plan(
"warnings": [],
}
- if isinstance(change_plan, dict) and not isinstance(
- change_plan.get("operations"), list
- ):
+ if isinstance(change_plan, dict) and not isinstance(change_plan.get("operations"), list):
return {
"valid": False,
"errors": [
@@ -248,17 +277,10 @@ def validate_graph_change_plan(
# lookups, while ensuring validation always uses one schema snapshot.
vertex_labels = _vertex_labels(raw_schema)
edge_labels = _edge_labels(raw_schema)
- vertex_properties = {
- label: _property_names(schema.get("properties"))
- for label, schema in vertex_labels.items()
- }
- edge_properties = {
- label: _property_names(schema.get("properties"))
- for label, schema in edge_labels.items()
- }
- primary_keys = {
- label: _primary_key_names(schema) for label, schema in vertex_labels.items()
- }
+ vertex_properties = {label: _property_names(schema.get("properties")) for label, schema in vertex_labels.items()}
+ edge_properties = {label: _property_names(schema.get("properties")) for label, schema in edge_labels.items()}
+ primary_keys = {label: _primary_key_names(schema) for label, schema in vertex_labels.items()}
+ specs = property_specs(raw_schema)
for idx, operation in enumerate(operations):
if not isinstance(operation, dict):
@@ -320,9 +342,7 @@ def validate_graph_change_plan(
allowed = vertex_properties.get(label, set())
pks = primary_keys.get(label, [])
if not pks:
- warnings.append(
- f"operation {idx} references vertex label '{label}' with no primary_keys"
- )
+ warnings.append(f"operation {idx} references vertex label '{label}' with no primary_keys")
# properties/match may reference only properties defined for the label.
# First apply the field allowlist, then enforce stricter op constraints.
_validate_field_map(
@@ -332,6 +352,14 @@ def validate_graph_change_plan(
allowed_properties=allowed,
errors=errors,
)
+ if op == "create_vertex":
+ _validate_property_values(
+ idx=idx,
+ operation=operation,
+ item_kind="vertex",
+ specs=specs,
+ errors=errors,
+ )
_validate_field_map(
idx=idx,
operation=operation,
@@ -369,10 +397,7 @@ def validate_graph_change_plan(
("source_label", source_label),
("target_label", target_label),
):
- if (
- not isinstance(endpoint_label, str)
- or endpoint_label not in vertex_labels
- ):
+ if not isinstance(endpoint_label, str) or endpoint_label not in vertex_labels:
errors.append(
_validation_error(
idx,
@@ -409,6 +434,14 @@ def validate_graph_change_plan(
allowed_properties=allowed,
errors=errors,
)
+ if op == "create_edge":
+ _validate_property_values(
+ idx=idx,
+ operation=operation,
+ item_kind="edge",
+ specs=specs,
+ errors=errors,
+ )
if isinstance(source_label, str) and source_label in vertex_labels:
_validate_primary_key_match(
idx=idx,
@@ -448,10 +481,8 @@ def validate_graph_change_plan(
# ---- Mode operation constraints ----
-def _validate_mode_operations(
- mode: str, change_plan: GraphChangePlan
-) -> dict[str, Any]:
- """确保 mode 下的所有操作类型匹配,例如 import 模式不允许删除。"""
+def _validate_mode_operations(mode: str, change_plan: GraphChangePlan) -> dict[str, Any]:
+ """确保 mode 下的所有操作类型匹配, 例如 import 模式不允许删除。"""
allowed = MODE_OPS.get(mode)
if allowed is None:
return {
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_write_adapter.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_write_adapter.py
new file mode 100644
index 00000000..4f00176a
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_write_adapter.py
@@ -0,0 +1,446 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Canonical graph write adapters and read-only reconciliation readers."""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Mapping
+from typing import Any
+
+from hugegraph_mcp import gremlin_tools
+from hugegraph_mcp.config import MCPConfig
+from hugegraph_mcp.guard import Capability
+from hugegraph_mcp.plan_store import PlanStore, plan_store_from_config
+from hugegraph_mcp.tools.graph_data_gremlin import _g, _has_steps, _write_query
+from hugegraph_mcp.tools.graph_write_plan import (
+ CREATE_EDGE,
+ CREATE_VERTEX,
+ DELETE_EDGE,
+ DELETE_VERTEX,
+)
+from hugegraph_mcp.write_plan import (
+ ApplyReceipt,
+ ApplyStatus,
+ OperationPlan,
+ WritePlan,
+)
+
+_AMBIGUOUS_ERRORS = {
+ "CONNECTION_FAILED",
+ "SERVER_ERROR",
+ "TIMEOUT",
+ "WRITE_OUTCOME_UNKNOWN",
+}
+
+
+def create_vertex_adapter(plan: WritePlan, operation: OperationPlan, attempt: int) -> ApplyReceipt:
+ mismatch = _target_mismatch_receipt(plan, operation, attempt)
+ if mismatch is not None:
+ return mismatch
+ before = _read_vertex(operation)
+ classified = _classify_create(before, operation)
+ if classified is not ApplyStatus.RETRYABLE_NOT_APPLIED:
+ if classified is ApplyStatus.APPLIED:
+ classified = ApplyStatus.ALREADY_APPLIED
+ return _receipt(plan, operation, attempt, classified, before)
+
+ write_result = gremlin_tools.execute_gremlin_write(
+ _write_query(dict(operation.target["mutation"])),
+ capability=Capability.DATA_WRITE,
+ )
+ after = _read_vertex(operation)
+ after_status = _classify_create(after, operation)
+ if after_status is ApplyStatus.APPLIED:
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.APPLIED,
+ after,
+ committed=True,
+ )
+ return _post_write_receipt(plan, operation, attempt, write_result, after_status, after)
+
+
+def create_edge_adapter(plan: WritePlan, operation: OperationPlan, attempt: int) -> ApplyReceipt:
+ mismatch = _target_mismatch_receipt(plan, operation, attempt)
+ if mismatch is not None:
+ return mismatch
+ source_id = _resolve_endpoint_id(plan, operation, "source")
+ target_id = _resolve_endpoint_id(plan, operation, "target")
+ endpoint_conflict = _validate_edge_endpoints(operation, source_id, target_id)
+ if endpoint_conflict is not None:
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.CONFLICT,
+ endpoint_conflict,
+ reason_code="ENDPOINT_STATE_CHANGED",
+ )
+
+ before = _read_edge(operation, source_id, target_id)
+ classified = _classify_create(before, operation)
+ if classified is not ApplyStatus.RETRYABLE_NOT_APPLIED:
+ if classified is ApplyStatus.APPLIED:
+ classified = ApplyStatus.ALREADY_APPLIED
+ return _receipt(plan, operation, attempt, classified, before)
+
+ mutation = dict(operation.target["mutation"])
+ mutation["source_id"] = source_id
+ mutation["target_id"] = target_id
+ write_result = gremlin_tools.execute_gremlin_write(_write_query(mutation), capability=Capability.DATA_WRITE)
+ after = _read_edge(operation, source_id, target_id)
+ after_status = _classify_create(after, operation)
+ if after_status is ApplyStatus.APPLIED:
+ observed = dict(after)
+ observed["source_id"] = source_id
+ observed["target_id"] = target_id
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.APPLIED,
+ observed,
+ committed=True,
+ )
+ return _post_write_receipt(plan, operation, attempt, write_result, after_status, after)
+
+
+def delete_vertex_adapter(plan: WritePlan, operation: OperationPlan, attempt: int) -> ApplyReceipt:
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.REJECTED,
+ {"capability": "isolated_vertex_delete", "supported": False},
+ reason_code="FEATURE_DISABLED",
+ )
+
+
+def delete_edge_adapter(plan: WritePlan, operation: OperationPlan, attempt: int) -> ApplyReceipt:
+ return _delete_adapter(plan, operation, attempt)
+
+
+def _delete_adapter(plan: WritePlan, operation: OperationPlan, attempt: int) -> ApplyReceipt:
+ mismatch = _target_mismatch_receipt(plan, operation, attempt)
+ if mismatch is not None:
+ return mismatch
+ before = _read_delete_target(operation)
+ if before.get("exists") is False:
+ return _receipt(plan, operation, attempt, ApplyStatus.ALREADY_APPLIED, before)
+ if before != dict(operation.expected_state):
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.CONFLICT,
+ before,
+ reason_code="DELETE_TARGET_CHANGED",
+ )
+ write_result = gremlin_tools.execute_gremlin_write(
+ _write_query(dict(operation.target["mutation"])),
+ capability=Capability.DATA_WRITE,
+ )
+ after = _read_delete_target(operation)
+ if after.get("exists") is False:
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.APPLIED,
+ after,
+ committed=True,
+ )
+ return _post_write_receipt(plan, operation, attempt, write_result, ApplyStatus.CONFLICT, after)
+
+
+def create_vertex_reader(_plan: WritePlan, operation: OperationPlan) -> Mapping[str, Any]:
+ return _reconcile_create_state(_read_vertex(operation), operation)
+
+
+def create_edge_reader(plan: WritePlan, operation: OperationPlan) -> Mapping[str, Any]:
+ source_id = _resolve_endpoint_id(plan, operation, "source")
+ target_id = _resolve_endpoint_id(plan, operation, "target")
+ return _reconcile_create_state(_read_edge(operation, source_id, target_id), operation)
+
+
+def delete_reader(_plan: WritePlan, operation: OperationPlan) -> Mapping[str, Any]:
+ return _read_delete_target(operation)
+
+
+def register_graph_write_adapters(registry: Any) -> None:
+ """Register only operations backed by verified HugeGraph 1.7 primitives."""
+
+ registry.register(DELETE_EDGE, delete_edge_adapter)
+
+
+def register_graph_reconcile_readers(registry: Any) -> None:
+ registry.register(CREATE_VERTEX, create_vertex_reader)
+ registry.register(CREATE_EDGE, create_edge_reader)
+ registry.register(DELETE_VERTEX, delete_reader)
+ registry.register(DELETE_EDGE, delete_reader)
+
+
+def _read_vertex(operation: OperationPlan) -> dict[str, Any]:
+ target = operation.target
+ identity = dict(target["identity"])
+ query = f"g.V().hasLabel({_g(operation.desired_state['label'])})"
+ if "id" in identity:
+ query += f".hasId({_g(identity['id'])})"
+ else:
+ query += _has_steps(dict(identity["primary_keys"]))
+ elements = _read_elements(f"{query}.limit(2).elementMap()")
+ return _create_observed_state(elements, operation)
+
+
+def _read_edge(operation: OperationPlan, source_id: Any, target_id: Any) -> dict[str, Any]:
+ query = (
+ f"g.V({_g(source_id)}).outE({_g(operation.desired_state['label'])})"
+ f".where(inV().hasId({_g(target_id)}))"
+ f"{_has_steps(dict(operation.target['identity_properties']))}"
+ ".limit(2).elementMap()"
+ )
+ elements = _read_elements(query)
+ return _create_observed_state(elements, operation)
+
+
+def _read_delete_target(operation: OperationPlan) -> dict[str, Any]:
+ expected = dict(operation.expected_state)
+ prefix = "g.V" if expected["element_type"] == "vertex" else "g.E"
+ elements = _read_elements(f"{prefix}({_g(expected['id'])}).limit(2).elementMap()")
+ if not elements:
+ return {
+ "exists": False,
+ "element_type": expected["element_type"],
+ "id": expected["id"],
+ "label": expected["label"],
+ }
+ if len(elements) != 1:
+ return {"exists": True, "ambiguous": True}
+ element = _normalize_element(elements[0])
+ return {
+ "exists": True,
+ "element_type": expected["element_type"],
+ "id": element.get("id", expected["id"]),
+ "label": element.get("label"),
+ }
+
+
+def _create_observed_state(elements: list[Any], operation: OperationPlan) -> dict[str, Any]:
+ if not elements:
+ return {"exists": False}
+ if len(elements) != 1:
+ return {"exists": True, "ambiguous": True, "matched_count": len(elements)}
+ element = _normalize_element(elements[0])
+ desired = dict(operation.desired_state)
+ observed_properties = {key: value for key, value in element.items() if key not in {"id", "label"}}
+ state: dict[str, Any] = {
+ "exists": True,
+ "element_type": desired["element_type"],
+ "label": element.get("label"),
+ "properties": observed_properties,
+ }
+ if "identity" in desired:
+ state["identity"] = dict(desired["identity"])
+ if "source" in desired:
+ state["source"] = dict(desired["source"])
+ state["target"] = dict(desired["target"])
+ state["identity_properties"] = dict(desired["identity_properties"])
+ if "id" in element:
+ state["id"] = element["id"]
+ return state
+
+
+def _classify_create(observed: Mapping[str, Any], operation: OperationPlan) -> ApplyStatus:
+ if observed.get("exists") is False:
+ return ApplyStatus.RETRYABLE_NOT_APPLIED
+ if observed.get("ambiguous"):
+ return ApplyStatus.CONFLICT
+ desired = dict(operation.desired_state)
+ comparable = dict(observed)
+ comparable.pop("id", None)
+ return ApplyStatus.APPLIED if comparable == desired else ApplyStatus.CONFLICT
+
+
+def _reconcile_create_state(observed: Mapping[str, Any], operation: OperationPlan) -> Mapping[str, Any]:
+ status = _classify_create(observed, operation)
+ if status is ApplyStatus.APPLIED:
+ return dict(operation.desired_state)
+ if status is ApplyStatus.RETRYABLE_NOT_APPLIED:
+ return dict(operation.expected_state)
+ return dict(observed)
+
+
+def _validate_edge_endpoints(operation: OperationPlan, source_id: Any, target_id: Any) -> dict[str, Any] | None:
+ mutation = operation.target["mutation"]
+ checks = (
+ ("source", source_id, mutation.get("source_label") or mutation.get("outVLabel")),
+ ("target", target_id, mutation.get("target_label") or mutation.get("inVLabel")),
+ )
+ for endpoint, endpoint_id, label in checks:
+ elements = _read_elements(f"g.V({_g(endpoint_id)}).hasLabel({_g(label)}).limit(2).elementMap()")
+ if len(elements) != 1:
+ return {
+ "exists": True,
+ "endpoint": endpoint,
+ "id": endpoint_id,
+ "label": label,
+ "matched_count": len(elements),
+ }
+ return None
+
+
+def _resolve_endpoint_id(
+ plan: WritePlan,
+ operation: OperationPlan,
+ endpoint: str,
+ *,
+ store: PlanStore | None = None,
+) -> Any:
+ endpoint_target = dict(operation.target[endpoint])
+ if "id" in endpoint_target:
+ return endpoint_target["id"]
+ dependency_id = endpoint_target["operation_id"]
+ store = store or plan_store_from_config()
+ record = store.get_plan_record(plan.plan_id)
+ if record is None:
+ raise RuntimeError("dependency plan is unavailable")
+ dependency = next(
+ (item for item in record["operations"] if item["operation_id"] == dependency_id),
+ None,
+ )
+ receipt = dependency.get("receipt") if dependency is not None else None
+ observed = receipt.get("observed_state") if isinstance(receipt, dict) else None
+ endpoint_id = observed.get("id") if isinstance(observed, dict) else None
+ if endpoint_id in (None, ""):
+ raise RuntimeError("dependency receipt has no stable vertex ID")
+ return endpoint_id
+
+
+def _read_elements(query: str) -> list[Any]:
+ result = gremlin_tools.execute_gremlin_read(query)
+ if isinstance(result, dict) and result.get("ok") is False:
+ raise RuntimeError(str(result.get("error", {}).get("type") or "READ_FAILED"))
+ if isinstance(result, dict) and result.get("success") is False:
+ raise RuntimeError(str(result.get("error_type") or "READ_FAILED"))
+ value: Any = result.get("data") if isinstance(result, dict) else result
+ while isinstance(value, dict) and "data" in value:
+ value = value["data"]
+ if value is None:
+ return []
+ return value if isinstance(value, list) else [value]
+
+
+def _normalize_element(element: Any) -> dict[str, Any]:
+ if not isinstance(element, Mapping):
+ return {"value": element}
+ normalized: dict[str, Any] = {}
+ for raw_key, value in element.items():
+ key = str(raw_key)
+ lowered = key.lower()
+ if lowered in {"id", "t.id", "~id"} or lowered.endswith(" id"):
+ normalized["id"] = value
+ elif lowered in {"label", "t.label", "~label"} or lowered.endswith(" label"):
+ normalized["label"] = value
+ else:
+ normalized[key] = value
+ return normalized
+
+
+def _post_write_receipt(
+ plan: WritePlan,
+ operation: OperationPlan,
+ attempt: int,
+ write_result: Any,
+ observed_status: ApplyStatus,
+ observed: Mapping[str, Any],
+) -> ApplyReceipt:
+ write_reported_success = isinstance(write_result, dict) and (
+ write_result.get("ok") is True or write_result.get("success") is True
+ )
+ if (
+ observed_status is ApplyStatus.CONFLICT
+ and operation.kind in {CREATE_VERTEX, CREATE_EDGE}
+ and write_reported_success
+ ):
+ status = ApplyStatus.UNKNOWN
+ reason = "POST_CREATE_IDENTITY_AMBIGUOUS"
+ elif observed_status is ApplyStatus.CONFLICT:
+ status = ApplyStatus.CONFLICT
+ reason = "POST_WRITE_STATE_CONFLICT"
+ elif _is_ambiguous_write_result(write_result):
+ status = ApplyStatus.UNKNOWN
+ reason = "WRITE_RESULT_AMBIGUOUS"
+ else:
+ status = ApplyStatus.REJECTED
+ reason = "WRITE_REJECTED_WITHOUT_SIDE_EFFECT"
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ status,
+ observed,
+ reason_code=reason,
+ reconciliation_required=status is ApplyStatus.UNKNOWN,
+ )
+
+
+def _is_ambiguous_write_result(result: Any) -> bool:
+ if not isinstance(result, dict):
+ return True
+ if result.get("ok") is False:
+ return str(result.get("error", {}).get("type")) in _AMBIGUOUS_ERRORS
+ if result.get("success") is False:
+ return str(result.get("error_type", "")).upper() in _AMBIGUOUS_ERRORS
+ return False
+
+
+def _target_mismatch_receipt(plan: WritePlan, operation: OperationPlan, attempt: int) -> ApplyReceipt | None:
+ cfg = MCPConfig.from_env()
+ current = (cfg.url, cfg.graph, cfg.graphspace or "DEFAULT", cfg.user)
+ planned = (
+ plan.graph_target.graph_url,
+ plan.graph_target.graph_name,
+ plan.graph_target.graphspace or "DEFAULT",
+ plan.principal,
+ )
+ if current == planned:
+ return None
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.REJECTED,
+ {"target_matches": False},
+ reason_code="TARGET_CHANGED",
+ )
+
+
+def _receipt(
+ plan: WritePlan,
+ operation: OperationPlan,
+ attempt: int,
+ status: ApplyStatus,
+ observed_state: Mapping[str, Any],
+ *,
+ reason_code: str | None = None,
+ reconciliation_required: bool = False,
+ committed: bool = False,
+) -> ApplyReceipt:
+ if reason_code is None:
+ reason_code = status.value
+ return ApplyReceipt(
+ plan_id=plan.plan_id,
+ operation_id=operation.operation_id,
+ status=status,
+ observed_state=observed_state,
+ reason_code=reason_code,
+ attempt=attempt,
+ reconciliation_required=reconciliation_required,
+ committed_at=int(time.time()) if committed else None,
+ )
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_write_plan.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_write_plan.py
new file mode 100644
index 00000000..b13c8923
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_write_plan.py
@@ -0,0 +1,230 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Compile validated graph changes into the canonical durable write model."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import time
+from typing import Any
+from uuid import uuid4
+
+from hugegraph_mcp.plan_hash import PlanContext
+from hugegraph_mcp.tools.graph_data_validate import _operations
+from hugegraph_mcp.tools.schema_utils import (
+ normalized_schema_summary,
+ primary_key_names,
+ schema_payload,
+)
+from hugegraph_mcp.write_plan import (
+ GraphTarget,
+ OperationPlan,
+ PlanStatus,
+ WritePlan,
+)
+
+CREATE_VERTEX = "CREATE_VERTEX"
+CREATE_EDGE = "CREATE_EDGE"
+DELETE_VERTEX = "DELETE_VERTEX"
+DELETE_EDGE = "DELETE_EDGE"
+GRAPH_OPERATION_KINDS = (CREATE_VERTEX, CREATE_EDGE, DELETE_VERTEX, DELETE_EDGE)
+
+
+def compile_graph_write_plan(
+ change_plan: Any,
+ *,
+ plan_context: PlanContext,
+ live_schema: dict[str, Any],
+ tool_name: str = "manage_graph_data",
+) -> WritePlan:
+ """Return an ordered immutable plan from a validated compiled change plan."""
+
+ plan_id = f"wp_{uuid4().hex}"
+ operation_ids = [f"{plan_id}:op:{index:04d}" for index, _ in enumerate(_operations(change_plan))]
+ canonical_operations: list[OperationPlan] = []
+ for index, raw in enumerate(_operations(change_plan)):
+ canonical_operations.append(
+ _compile_operation(
+ raw,
+ operation_id=operation_ids[index],
+ operation_ids=operation_ids,
+ live_schema=live_schema,
+ )
+ )
+
+ created_at = int(time.time())
+ expires_at = max(created_at + 1, int(plan_context.expires_at))
+ payload_digest = _digest(change_plan)
+ return WritePlan(
+ plan_id=plan_id,
+ tool_name=tool_name,
+ graph_target=GraphTarget(
+ graph_url=plan_context.graph_url,
+ graph_name=plan_context.graph_name,
+ graphspace=plan_context.graphspace,
+ ),
+ principal=plan_context.principal,
+ operations=tuple(canonical_operations),
+ payload_digest=payload_digest,
+ schema_fingerprint=_digest(normalized_schema_summary(live_schema)),
+ status=PlanStatus.ISSUED,
+ created_at=created_at,
+ expires_at=expires_at,
+ )
+
+
+def _compile_operation(
+ raw: dict[str, Any],
+ *,
+ operation_id: str,
+ operation_ids: list[str],
+ live_schema: dict[str, Any],
+) -> OperationPlan:
+ mutation = dict(raw)
+ op = str(raw.get("op") or raw.get("type"))
+ if op == "create_vertex":
+ identity = _vertex_identity(raw, live_schema)
+ desired = {
+ "exists": True,
+ "element_type": "vertex",
+ "label": raw["label"],
+ "identity": identity,
+ "properties": dict(raw.get("properties") or {}),
+ }
+ return _operation(
+ operation_id,
+ CREATE_VERTEX,
+ {"mutation": mutation, "identity": identity},
+ {"exists": False},
+ desired,
+ )
+ if op == "create_edge":
+ source, source_dependencies = _endpoint_target(raw, "source", operation_ids)
+ target, target_dependencies = _endpoint_target(raw, "target", operation_ids)
+ identity_properties = _edge_identity_properties(raw, live_schema)
+ desired = {
+ "exists": True,
+ "element_type": "edge",
+ "label": raw["label"],
+ "source": source,
+ "target": target,
+ "identity_properties": identity_properties,
+ "properties": dict(raw.get("properties") or {}),
+ }
+ dependencies = tuple(dict.fromkeys(source_dependencies + target_dependencies))
+ return _operation(
+ operation_id,
+ CREATE_EDGE,
+ {
+ "mutation": mutation,
+ "source": source,
+ "target": target,
+ "identity_properties": identity_properties,
+ },
+ {"exists": False},
+ desired,
+ depends_on=dependencies,
+ )
+ if op in {"delete_vertex", "delete_edge"}:
+ target_id = raw.get("target_id")
+ if target_id in (None, ""):
+ raise ValueError(f"{op} requires a compiled target_id")
+ element_type = "vertex" if op == "delete_vertex" else "edge"
+ kind = DELETE_VERTEX if op == "delete_vertex" else DELETE_EDGE
+ return _operation(
+ operation_id,
+ kind,
+ {"mutation": mutation, "id": target_id, "label": raw["label"]},
+ {
+ "exists": True,
+ "element_type": element_type,
+ "id": target_id,
+ "label": raw["label"],
+ },
+ {"exists": False},
+ )
+ raise ValueError(f"unsupported graph operation: {op}")
+
+
+def _operation(
+ operation_id: str,
+ kind: str,
+ target: dict[str, Any],
+ expected_state: dict[str, Any],
+ desired_state: dict[str, Any],
+ *,
+ depends_on: tuple[str, ...] = (),
+) -> OperationPlan:
+ return OperationPlan(
+ operation_id=operation_id,
+ kind=kind,
+ target=target,
+ expected_state=expected_state,
+ desired_state=desired_state,
+ depends_on=depends_on,
+ idempotency_key=_digest({"kind": kind, "target": target, "desired_state": desired_state}),
+ )
+
+
+def _endpoint_target(raw: dict[str, Any], endpoint: str, operation_ids: list[str]) -> tuple[dict[str, Any], list[str]]:
+ dependency_index = raw.get(f"{endpoint}_operation_index")
+ if dependency_index is not None:
+ if not isinstance(dependency_index, int) or dependency_index < 0 or dependency_index >= len(operation_ids):
+ raise ValueError(f"invalid {endpoint} dependency operation index")
+ operation_id = operation_ids[dependency_index]
+ return {"operation_id": operation_id}, [operation_id]
+ endpoint_id = raw.get(f"{endpoint}_id")
+ if endpoint_id in (None, ""):
+ raise ValueError(f"create_edge requires compiled {endpoint}_id")
+ return {"id": endpoint_id}, []
+
+
+def _vertex_identity(operation: dict[str, Any], live_schema: dict[str, Any]) -> dict[str, Any]:
+ explicit_id = operation.get("id")
+ if explicit_id not in (None, ""):
+ return {"id": explicit_id}
+ label = _vertex_label(live_schema, str(operation["label"]))
+ primary_keys = primary_key_names(label) if label is not None else []
+ properties = operation.get("properties") or {}
+ if not primary_keys or any(key not in properties for key in primary_keys):
+ raise ValueError("create_vertex requires a stable ID or complete primary-key identity")
+ return {"primary_keys": {key: properties[key] for key in primary_keys}}
+
+
+def _edge_identity_properties(operation: dict[str, Any], live_schema: dict[str, Any]) -> dict[str, Any]:
+ edge_label = _edge_label(live_schema, str(operation["label"]))
+ if edge_label is None:
+ raise ValueError(f"unknown edge label: {operation['label']}")
+ sort_keys = edge_label.get("sort_keys") or edge_label.get("sortKeys") or []
+ frequency = str(edge_label.get("frequency") or "SINGLE").upper()
+ properties = operation.get("properties") or {}
+ if frequency == "MULTIPLE" and not sort_keys:
+ raise ValueError("MULTIPLE create_edge requires schema sort keys for stable identity")
+ if any(key not in properties for key in sort_keys):
+ raise ValueError("create_edge is missing a required sort-key identity value")
+ return {key: properties[key] for key in sort_keys}
+
+
+def _vertex_label(live_schema: dict[str, Any], name: str) -> dict[str, Any] | None:
+ schema = schema_payload(live_schema) or {}
+ return next(
+ (item for item in schema.get("vertexlabels") or [] if isinstance(item, dict) and item.get("name") == name),
+ None,
+ )
+
+
+def _edge_label(live_schema: dict[str, Any], name: str) -> dict[str, Any] | None:
+ schema = schema_payload(live_schema) or {}
+ return next(
+ (item for item in schema.get("edgelabels") or [] if isinstance(item, dict) and item.get("name") == name),
+ None,
+ )
+
+
+def _digest(value: Any) -> str:
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py
index 94fb391a..6b8b8ff2 100644
--- a/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py
+++ b/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py
@@ -40,20 +40,18 @@ validate_graph_payload() 对 vertices/edges 做全面 schema 校验:
import json
from copy import deepcopy
from typing import Any
-from uuid import uuid4
from hugegraph_mcp.config import MCPConfig
-from hugegraph_mcp.confirmable_workflow import (
- issue_plan,
- mark_readonly_preview,
- plan_hash_error,
- replayed_plan_error,
- verify_and_consume_plan,
-)
+from hugegraph_mcp.confirmable_workflow import mark_readonly_preview
from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok
from hugegraph_mcp.guard import Capability, guard
-from hugegraph_mcp.hugegraph_ai_client import post
from hugegraph_mcp.tools.live_schema import fetch_live_schema_or_none
+from hugegraph_mcp.tools.property_validation import (
+ property_specs as _property_specs,
+)
+from hugegraph_mcp.tools.property_validation import (
+ property_value_error as _property_value_error,
+)
from hugegraph_mcp.tools.schema_utils import (
edge_schema_endpoint_label as _edge_schema_endpoint_label,
)
@@ -69,96 +67,7 @@ from hugegraph_mcp.tools.schema_utils import (
from hugegraph_mcp.tools.schema_utils import (
schema_payload as _schema_payload,
)
-
-_DATA_TYPE_ALIASES = {"INTEGER": "INT", "BOOL": "BOOLEAN"}
-_SUPPORTED_DATA_TYPES = {
- "TEXT",
- "UUID",
- "INT",
- "LONG",
- "DOUBLE",
- "FLOAT",
- "BOOLEAN",
- "DATE",
- "BYTE",
- "BLOB",
- "OBJECT",
-}
-_SUPPORTED_CARDINALITIES = {"SINGLE", "LIST", "SET"}
-
-
-def _property_specs(raw_schema: dict[str, Any]) -> dict[str, tuple[str, str]]:
- specs: dict[str, tuple[str, str]] = {}
- normalized_schema = normalized_schema_summary(raw_schema) or {}
- for prop in normalized_schema.get("propertykeys", []):
- if not isinstance(prop, dict):
- continue
- name = prop.get("name")
- data_type = prop.get("data_type")
- if isinstance(name, str) and isinstance(data_type, str):
- cardinality = prop.get("cardinality") or "SINGLE"
- normalized_data_type = data_type.strip().upper()
- specs[name] = (
- _DATA_TYPE_ALIASES.get(normalized_data_type, normalized_data_type),
- str(cardinality).strip().upper(),
- )
- return specs
-
-
-def _value_matches_type(value: Any, data_type: str) -> bool:
- if value is None:
- return True
- if data_type in {"TEXT", "UUID"}:
- return isinstance(value, str)
- if data_type in {"INT", "LONG", "BYTE"}:
- return isinstance(value, int) and not isinstance(value, bool)
- if data_type in {"FLOAT", "DOUBLE"}:
- return isinstance(value, (int, float)) and not isinstance(value, bool)
- if data_type == "BOOLEAN":
- return isinstance(value, bool)
- if data_type in {"DATE", "BLOB"}:
- return isinstance(value, str)
- if data_type == "OBJECT":
- return isinstance(value, dict)
- return False
-
-
-def _property_value_error(
- *,
- item_kind: str,
- item_index: int,
- property_name: str,
- value: Any,
- spec: tuple[str, str] | None,
-) -> str | None:
- if spec is None:
- return None
-
- data_type, cardinality = spec
- prefix = f"{item_kind} {item_index} property '{property_name}'"
- if data_type not in _SUPPORTED_DATA_TYPES:
- return f"{prefix} unsupported data_type '{data_type}'"
- if cardinality not in _SUPPORTED_CARDINALITIES:
- return f"{prefix} unsupported cardinality '{cardinality}'"
- if cardinality in {"LIST", "SET"}:
- if value is None:
- return None
- if not isinstance(value, list):
- return (
- f"{prefix} expects {cardinality} of {data_type}, "
- f"got {type(value).__name__}"
- )
- for element_index, element in enumerate(value):
- if element is None or not _value_matches_type(element, data_type):
- return (
- f"{prefix} element {element_index} expects {data_type}, "
- f"got {type(element).__name__}"
- )
- return None
-
- if not _value_matches_type(value, data_type):
- return f"{prefix} expects {data_type}, got {type(value).__name__}"
- return None
+from hugegraph_mcp.write_plan import ApplyStatus, PlanStatus, aggregate_plan_status
def _indexed_labels(raw_schema: dict[str, Any]) -> dict[str, set[str]]:
@@ -217,18 +126,12 @@ def _endpoint_identities(
if _identity_value_present(explicit_id):
identities.append((label, "id", explicit_id))
if primary_keys:
- missing = [
- pk
- for pk in primary_keys
- if pk not in value or not _identity_value_present(value.get(pk))
- ]
+ missing = [pk for pk in primary_keys if pk not in value or not _identity_value_present(value.get(pk))]
if missing:
if identities:
return identities, None
return identities, missing[0]
- identities.append(
- (label, "pk", tuple(value.get(pk) for pk in primary_keys))
- )
+ identities.append((label, "pk", tuple(value.get(pk) for pk in primary_keys)))
return identities, None
if _identity_value_present(value):
@@ -259,10 +162,7 @@ def _canonical_json_key(value: Any) -> str:
def _normalize_value(value: Any) -> Any:
if isinstance(value, dict):
- return {
- key: _normalize_value(value[key])
- for key in sorted(value, key=lambda item: str(item))
- }
+ return {key: _normalize_value(value[key]) for key in sorted(value, key=lambda item: str(item))}
if isinstance(value, list):
return [_normalize_value(item) for item in value]
return value
@@ -281,7 +181,7 @@ def _vertex_sort_key(
props = vertex.get("properties")
primary_keys = schema_primary_keys.get(label, [])
if isinstance(props, dict) and primary_keys:
- identity = props.get(primary_keys[0])
+ identity = tuple(props.get(primary_key) for primary_key in primary_keys)
elif isinstance(props, dict) and props:
first_key = min(props, key=lambda item: str(item))
identity = props.get(first_key)
@@ -353,9 +253,7 @@ def _schema_vertex_info(raw_schema: dict[str, Any]) -> dict[str, dict[str, Any]]
info[name] = {
"id": vertex_label.get("id"),
"id_strategy": str(
- vertex_label.get("id_strategy")
- or vertex_label.get("idStrategy")
- or "PRIMARY_KEY"
+ vertex_label.get("id_strategy") or vertex_label.get("idStrategy") or "PRIMARY_KEY"
).upper(),
"primary_keys": _primary_key_names(vertex_label),
}
@@ -392,9 +290,7 @@ def _vertex_backend_id(
primary_keys = vertex_info.get(label, {}).get("primary_keys", [])
if not primary_keys:
return None
- if not all(
- pk in props and _identity_value_present(props.get(pk)) for pk in primary_keys
- ):
+ if not all(pk in props and _identity_value_present(props.get(pk)) for pk in primary_keys):
return None
values = tuple(props.get(pk) for pk in primary_keys)
@@ -409,9 +305,7 @@ def _vertex_identity_map(
# A vertex may have an explicit ID, a PRIMARY_KEY backend ID, and a primary-key
# tuple; resolving any of them must target the same backend vertex.
vertex_info = _schema_vertex_info(raw_schema)
- schema_primary_keys = {
- label: info.get("primary_keys", []) for label, info in vertex_info.items()
- }
+ schema_primary_keys = {label: info.get("primary_keys", []) for label, info in vertex_info.items()}
identities: dict[tuple[str, str, Any], Any] = {}
for vertex in vertices:
@@ -435,10 +329,7 @@ def _vertex_identity_map(
if (
isinstance(props, dict)
and primary_keys
- and all(
- pk in props and _identity_value_present(props.get(pk))
- for pk in primary_keys
- )
+ and all(pk in props and _identity_value_present(props.get(pk)) for pk in primary_keys)
):
values = tuple(props.get(pk) for pk in primary_keys)
identities[(label, "pk", values)] = backend_id or explicit_id
@@ -470,10 +361,7 @@ def _endpoint_backend_id(
return explicit_id
primary_keys = schema_primary_keys.get(label, [])
- if primary_keys and all(
- pk in value and _identity_value_present(value.get(pk))
- for pk in primary_keys
- ):
+ if primary_keys and all(pk in value and _identity_value_present(value.get(pk)) for pk in primary_keys):
values = tuple(value.get(pk) for pk in primary_keys)
return _canonical_primary_key_id(label, values, vertex_info)
return None
@@ -579,9 +467,7 @@ def validate_graph_payload(
schema_vlabels.add(name)
schema_props[name] = _property_names(vl.get("properties", []))
schema_primary_keys[name] = _primary_key_names(vl)
- schema_id_strategies[name] = str(
- vl.get("id_strategy") or vl.get("idStrategy") or "PRIMARY_KEY"
- ).upper()
+ schema_id_strategies[name] = str(vl.get("id_strategy") or vl.get("idStrategy") or "PRIMARY_KEY").upper()
for el in raw.get("edgelabels", []):
if not isinstance(el, dict):
continue
@@ -611,17 +497,9 @@ def validate_graph_payload(
schema_prop_names = schema_props.get(label, set())
for prop_name, prop_value in props.items():
if prop_value is None or prop_value == "":
- warnings.append(
- f"vertex {idx} property '{prop_name}' has empty value"
- )
- if (
- schema_available
- and label in schema_props
- and prop_name not in schema_prop_names
- ):
- errors.append(
- f"vertex {idx} property '{prop_name}' does not exist on label '{label}'"
- )
+ warnings.append(f"vertex {idx} property '{prop_name}' has empty value")
+ if schema_available and label in schema_props and prop_name not in schema_prop_names:
+ errors.append(f"vertex {idx} property '{prop_name}' does not exist on label '{label}'")
value_error = _property_value_error(
item_kind="vertex",
item_index=idx,
@@ -636,9 +514,7 @@ def validate_graph_payload(
explicit_id = vertex.get("id")
if id_strategy == "CUSTOMIZE_STRING":
if not _identity_value_present(explicit_id):
- errors.append(
- f"vertex {idx} missing required id for CUSTOMIZE_STRING label '{label}'"
- )
+ errors.append(f"vertex {idx} missing required id for CUSTOMIZE_STRING label '{label}'")
elif not isinstance(explicit_id, str):
errors.append(
f"vertex {idx} id for CUSTOMIZE_STRING label '{label}' must be a string, "
@@ -646,9 +522,7 @@ def validate_graph_payload(
)
elif id_strategy == "CUSTOMIZE_NUMBER":
if not _identity_value_present(explicit_id):
- errors.append(
- f"vertex {idx} missing required id for CUSTOMIZE_NUMBER label '{label}'"
- )
+ errors.append(f"vertex {idx} missing required id for CUSTOMIZE_NUMBER label '{label}'")
elif not isinstance(explicit_id, int) or isinstance(explicit_id, bool):
errors.append(
f"vertex {idx} id for CUSTOMIZE_NUMBER label '{label}' must be an integer, "
@@ -661,13 +535,8 @@ def validate_graph_payload(
props = {}
for pk in primary_keys:
if pk not in props or not _identity_value_present(props.get(pk)):
- errors.append(
- f"vertex {idx} missing primary key value for label '{label}': {pk}"
- )
- if all(
- pk in props and _identity_value_present(props.get(pk))
- for pk in primary_keys
- ):
+ errors.append(f"vertex {idx} missing primary key value for label '{label}': {pk}")
+ if all(pk in props and _identity_value_present(props.get(pk)) for pk in primary_keys):
identity = (
label,
"pk",
@@ -704,11 +573,13 @@ def validate_graph_payload(
tgt_label, target = _edge_endpoint(edge, "target")
if _has_mixed_endpoint_forms(edge, "source"):
errors.append(
- f"edge {idx} mixes source and outV endpoint forms; use either source/source_label or outV/outVLabel, not both"
+ f"edge {idx} mixes source and outV endpoint forms; use either "
+ "source/source_label or outV/outVLabel, not both"
)
if _has_mixed_endpoint_forms(edge, "target"):
errors.append(
- f"edge {idx} mixes target and inV endpoint forms; use either target/target_label or inV/inVLabel, not both"
+ f"edge {idx} mixes target and inV endpoint forms; use either "
+ "target/target_label or inV/inVLabel, not both"
)
if label in (None, ""):
errors.append(f"edge {idx} missing required field: label")
@@ -719,46 +590,34 @@ def validate_graph_payload(
if label:
edge_labels.add(label)
if schema_available and label not in schema_elabels:
- errors.append(
- f"edge {idx} label '{label}' does not exist in schema"
- )
+ errors.append(f"edge {idx} label '{label}' does not exist in schema")
if schema_available:
if src_label and src_label not in schema_vlabels:
- errors.append(
- f"edge {idx} source_label '{src_label}' does not exist in schema"
- )
+ errors.append(f"edge {idx} source_label '{src_label}' does not exist in schema")
if tgt_label and tgt_label not in schema_vlabels:
- errors.append(
- f"edge {idx} target_label '{tgt_label}' does not exist in schema"
- )
+ errors.append(f"edge {idx} target_label '{tgt_label}' does not exist in schema")
if label and label in schema_elabels:
schema_edge = schema_elabels[label]
expected_src = _edge_schema_endpoint_label(schema_edge, "source")
expected_tgt = _edge_schema_endpoint_label(schema_edge, "target")
if src_label and expected_src and src_label != expected_src:
errors.append(
- f"edge {idx} source_label '{src_label}' does not match edge label '{label}' source_label '{expected_src}'"
+ f"edge {idx} source_label '{src_label}' does not match edge "
+ f"label '{label}' source_label '{expected_src}'"
)
if tgt_label and expected_tgt and tgt_label != expected_tgt:
errors.append(
- f"edge {idx} target_label '{tgt_label}' does not match edge label '{label}' target_label '{expected_tgt}'"
+ f"edge {idx} target_label '{tgt_label}' does not match edge "
+ f"label '{label}' target_label '{expected_tgt}'"
)
props = edge.get("properties")
if isinstance(props, dict):
schema_prop_names = schema_eprops.get(label, set())
for prop_name, prop_value in props.items():
if prop_value is None or prop_value == "":
- warnings.append(
- f"edge {idx} property '{prop_name}' has empty value"
- )
- if (
- schema_available
- and label in schema_eprops
- and prop_name not in schema_prop_names
- ):
- errors.append(
- f"edge {idx} property '{prop_name}' does not exist on label '{label}'"
- )
+ warnings.append(f"edge {idx} property '{prop_name}' has empty value")
+ if schema_available and label in schema_eprops and prop_name not in schema_prop_names:
+ errors.append(f"edge {idx} property '{prop_name}' does not exist on label '{label}'")
value_error = _property_value_error(
item_kind="edge",
item_index=idx,
@@ -769,10 +628,11 @@ def validate_graph_payload(
if value_error:
errors.append(value_error)
if source is None and target is None:
- continue
- if source is None:
+ errors.append(f"edge {idx} missing source endpoint")
+ errors.append(f"edge {idx} missing target endpoint")
+ elif source is None:
errors.append(f"edge {idx} has target but missing source")
- if target is None:
+ elif target is None:
errors.append(f"edge {idx} has source but missing target")
for endpoint_name, endpoint_label, endpoint_value in (
("source", src_label, source),
@@ -789,7 +649,8 @@ def validate_graph_payload(
# Report an error when an endpoint object lacks a primary key; do not fall back
# to string matching, which could silently write invalid dangling edges.
errors.append(
- f"edge {idx} {endpoint_name} endpoint missing primary key for label '{endpoint_label}': {missing_pk}"
+ f"edge {idx} {endpoint_name} endpoint missing primary key "
+ f"for label '{endpoint_label}': {missing_pk}"
)
continue
if not identities:
@@ -801,9 +662,7 @@ def validate_graph_payload(
)
continue
matched_vertex_indices = {
- vertex_identity_index[identity]
- for identity in identities
- if identity in vertex_identity_index
+ vertex_identity_index[identity] for identity in identities if identity in vertex_identity_index
}
if len(matched_vertex_indices) > 1:
errors.append(
@@ -904,11 +763,6 @@ def ingest_graph_data_via_ai(
dry_run=False + confirm=True + plan_hash 匹配: 执行写入
nonce/expires_at: dry_run 返回的 plan_context 中的字段,confirm 时必须传回
"""
- if not dry_run and confirm:
- replay_error = replayed_plan_error(nonce)
- if replay_error is not None:
- return replay_error
-
from hugegraph_mcp.write_limits import (
graph_data_operation_count,
write_limit_envelope,
@@ -982,7 +836,8 @@ def ingest_graph_data_via_ai(
},
"mutation_summary": mutation_summary,
"warnings": warnings,
- "confirmable": True,
+ "confirmable": False,
+ "preview_only": True,
}
next_actions: list[str] = []
if MCPConfig.from_env().is_readonly():
@@ -992,15 +847,16 @@ def ingest_graph_data_via_ai(
"This dry-run was generated while HUGEGRAPH_MCP_READONLY=true. "
"Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirming."
),
- next_action=(
- "Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm."
- ),
+ next_action=("Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm."),
)
warnings = list(warnings) + readonly_warnings
- else:
- issue_error = issue_plan(plan_context, plan_hash_value)
- if issue_error is not None:
- return issue_error
+ warnings = list(warnings) + [
+ (
+ "Legacy HugeGraph-AI graph import is preview-only because it has no "
+ "durable per-operation receipts or verified create-if-absent semantics."
+ )
+ ]
+ next_actions.append("Do not confirm this import until atomic graph-create capabilities are verified.")
return envelope_ok(payload, warnings=warnings, next_actions=next_actions)
violation = guard(Capability.DATA_WRITE)
@@ -1011,103 +867,44 @@ def ingest_graph_data_via_ai(
return envelope_err(
ErrorType.CONFIRM_REQUIRED,
"Graph data import requires confirm=True after a dry_run.",
- suggestion="Run dry_run=True, review mutation_summary and warnings, then pass confirm=True with the returned plan_hash.",
- )
-
- # Validate against the target by rereading config and schema and recomputing the hash.
- # nonce must be returned from the plan_context produced by dry_run.
- valid, error_type, details = verify_and_consume_plan(
- submitted_hash=plan_hash,
- tool_name="ingest_graph_data",
- mode="import",
- payload_digest=payload_digest,
- schema_hash=schema_hash,
- nonce=nonce or plan_context.nonce,
- expires_at=expires_at,
- )
- if not valid:
- return plan_hash_error(
- error_type=error_type,
- details=details,
- mismatch_message=(
- "Plan hash mismatch: config, schema, or payload has changed since dry_run."
- ),
- expired_message=(
- "Plan has expired. Run dry_run=True again and use the returned plan_hash."
+ suggestion=(
+ "Run dry_run=True, review mutation_summary and warnings, then pass "
+ "confirm=True with the returned plan_hash."
),
- suggestion="Run dry_run=True again and use the returned plan_hash.",
)
-
- batch_id = f"batch-{uuid4().hex[:12]}"
- request_id = f"req-{uuid4().hex[:12]}"
- cfg = MCPConfig.from_env()
- planned = mutation_summary
-
- import_data = _prepare_graph_import_data(graph_data, live_schema)
- # Actual writes still use HugeGraph-AI's graph-import HTTP endpoint. MCP handles
- # schema validation, safety confirmation, and payload normalization rather than
- # invoking the hugegraph-llm import flow directly.
- try:
- ai_result = post(
- "/graph-import",
- json={"data": json.dumps(import_data, sort_keys=True), "schema": cfg.graph},
- )
- except Exception as exc: # noqa: BLE001 - normalize import boundary failures
- return envelope_err(
- ErrorType.FLOW_EXECUTION_FAILED,
- f"Import execution failed: {exc}",
- details=_import_error_result(
- planned=planned, batch_id=batch_id, request_id=request_id, cfg=cfg
- ),
- )
-
- if not ai_result.get("ok"):
- # M5: Normalize AI error responses instead of passing through raw results.
- return envelope_err(
- ErrorType.FLOW_EXECUTION_FAILED,
- "HugeGraph-AI import returned an error.",
- details=_normalize_import_result(
- ai_result=None,
- planned=planned,
- batch_id=batch_id,
- request_id=request_id,
- cfg=cfg,
- ),
- )
-
- import_result = _unwrap_ai_payload(ai_result.get("data"))
- if isinstance(import_result, dict) and import_result.get("ok") is False:
- return envelope_err(
- ErrorType.FLOW_EXECUTION_FAILED,
- "HugeGraph-AI import returned an error in payload.",
- details=_normalize_import_result(
- ai_result=None,
- planned=planned,
- batch_id=batch_id,
- request_id=request_id,
- cfg=cfg,
- ),
- )
-
- # M5: Normalize the import result.
- normalized = _normalize_import_result(
- ai_result=import_result,
- planned=planned,
- batch_id=batch_id,
- request_id=request_id,
- cfg=cfg,
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "Legacy HugeGraph-AI graph import execution is disabled.",
+ suggestion=(
+ "Use dry_run for validation only. Enable graph import only after the "
+ "canonical workflow has verified atomic create adapters."
+ ),
+ retryable=False,
)
- if normalized.get("status") != "success":
- return envelope_err(
- ErrorType.FLOW_EXECUTION_FAILED,
- "Graph data import did not complete successfully.",
- retryable=bool(normalized.get("retryable")),
- details=normalized,
- warnings=normalized.get("warnings", []),
- )
- return envelope_ok(normalized, warnings=normalized.get("warnings", []))
+def _unknown_import_result(
+ *,
+ planned: dict[str, int],
+ batch_id: str,
+ request_id: str,
+ cfg: MCPConfig,
+ cause: Any,
+) -> dict[str, Any]:
+ return {
+ "status": PlanStatus.UNKNOWN.value,
+ "planned": planned,
+ "batch_id": batch_id,
+ "request_id": request_id,
+ "target": {
+ "graph_url": cfg.url,
+ "graph_name": cfg.graph,
+ "graphspace": cfg.graphspace or "DEFAULT",
+ },
+ "cause": cause,
+ "retryable": False,
+ "reconciliation_required": True,
+ }
def _mutation_summary(graph_data: dict[str, Any]) -> dict[str, int]:
@@ -1134,7 +931,8 @@ def _normalize_import_result(
) -> dict[str, Any]:
"""将 AI 导入结果规范化为 V1 标准格式。
- M5: success / partial / degraded / error
+ The remote service's legacy success/partial/degraded/error vocabulary is
+ interpreted as evidence, then exposed through the canonical plan state model.
"""
target = {
"graph_url": cfg.url,
@@ -1142,11 +940,10 @@ def _normalize_import_result(
"graphspace": cfg.graphspace or "DEFAULT",
}
- if ai_result is None or (
- isinstance(ai_result, dict) and ai_result.get("ok") is False
- ):
+ if ai_result is None or (isinstance(ai_result, dict) and ai_result.get("ok") is False):
return {
- "status": "error",
+ "status": PlanStatus.UNKNOWN.value,
+ "import_classification": "error",
"planned": planned,
"written": {"vertices": 0, "edges": 0},
"failed_items": [],
@@ -1172,25 +969,15 @@ def _normalize_import_result(
if failed_items:
written = {"vertices": 0, "edges": 0}
status = "error" if remote_success is False else "degraded"
- warnings.append(
- "HugeGraph-AI import returned failures without explicit written counts."
- )
+ warnings.append("HugeGraph-AI import returned failures without explicit written counts.")
elif remote_success is False:
written = {"vertices": 0, "edges": 0}
status = "error"
- warnings.append(
- "HugeGraph-AI import reported failure without explicit written counts."
- )
+ warnings.append("HugeGraph-AI import reported failure without explicit written counts.")
else:
written = {"vertices": 0, "edges": 0}
- status = (
- remote_status
- if remote_status in {"partial", "error", "degraded"}
- else "degraded"
- )
- warnings.append(
- "HugeGraph-AI import did not return explicit written counts; write outcome is unknown."
- )
+ status = remote_status if remote_status in {"partial", "error", "degraded"} else "degraded"
+ warnings.append("HugeGraph-AI import did not return explicit written counts; write outcome is unknown.")
elif remote_success is False and (written["vertices"] > 0 or written["edges"] > 0):
status = "partial"
elif remote_success is False:
@@ -1208,11 +995,18 @@ def _normalize_import_result(
if count_source == "total":
warnings.append(
- "HugeGraph-AI import returned only a total written count; vertices/edges were estimated from the planned import order."
+ "HugeGraph-AI import returned only a total written count; vertices/edges "
+ "were estimated from the planned import order."
)
+ canonical_status = _canonical_import_status(
+ classification=status,
+ count_source=count_source,
+ written=written,
+ )
return {
- "status": status,
+ "status": canonical_status.value,
+ "import_classification": status,
"planned": planned,
"written": written,
"failed_items": failed_items,
@@ -1220,7 +1014,7 @@ def _normalize_import_result(
"retryable": False,
"compensation_suggestions": (
["Review failed_items, inspect graph state, and run a fresh dry-run."]
- if status in {"partial", "degraded", "error"}
+ if canonical_status is not PlanStatus.APPLIED
else []
),
"target": target,
@@ -1229,9 +1023,26 @@ def _normalize_import_result(
}
-def _extract_written_counts(
- ai_result: Any, planned: dict[str, int]
-) -> tuple[dict[str, int] | None, str | None]:
+def _canonical_import_status(
+ *,
+ classification: str,
+ count_source: str | None,
+ written: dict[str, int],
+) -> PlanStatus:
+ """Convert legacy import evidence into the shared write state model."""
+
+ if classification == "success":
+ operation_statuses = [ApplyStatus.APPLIED]
+ elif classification == "partial":
+ operation_statuses = [ApplyStatus.APPLIED, ApplyStatus.REJECTED]
+ elif classification == "error" and count_source is not None and not any(written.values()):
+ operation_statuses = [ApplyStatus.REJECTED]
+ else:
+ operation_statuses = [ApplyStatus.UNKNOWN]
+ return aggregate_plan_status(operation_statuses)
+
+
+def _extract_written_counts(ai_result: Any, planned: dict[str, int]) -> tuple[dict[str, int] | None, str | None]:
"""从 AI 结果中提取写入计数。
返回 (counts, source),无法识别时 counts 为 None。source="total" 表示
@@ -1332,7 +1143,8 @@ def _import_error_result(
) -> dict[str, Any]:
"""构建导入执行错误的规范化结果。"""
return {
- "status": "error",
+ "status": PlanStatus.UNKNOWN.value,
+ "import_classification": "error",
"planned": planned,
"written": {"vertices": 0, "edges": 0},
"failed_items": [],
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py
index 9f273e4a..5b9b97a0 100644
--- a/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py
+++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py
@@ -25,10 +25,12 @@ from hugegraph_mcp.config import MCPConfig
from hugegraph_mcp.confirmable_workflow import (
confirm_required_error,
issue_plan,
+ load_issued_plan,
mark_readonly_preview,
plan_hash_error,
+ record_write_outcome,
replayed_plan_error,
- verify_and_consume_plan,
+ verify_and_consume_issued_plan,
)
from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok
from hugegraph_mcp.guard import Capability, guard
@@ -37,6 +39,7 @@ from hugegraph_mcp.plan_hash import (
compute_payload_digest,
compute_plan_hash,
)
+from hugegraph_mcp.plan_store import plan_store_from_config
from hugegraph_mcp.tools import ingest_graph_data
from hugegraph_mcp.tools.graph_data_execute import (
_fetch_live_schema,
@@ -56,11 +59,17 @@ from hugegraph_mcp.tools.graph_data_validate import (
_validate_mode_operations,
validate_graph_change_plan,
)
+from hugegraph_mcp.tools.graph_write_plan import compile_graph_write_plan
from hugegraph_mcp.write_limits import (
graph_data_operation_count,
operation_count_from_list,
write_limit_envelope,
)
+from hugegraph_mcp.write_plan import (
+ ApplyStatus,
+ PlanStatus,
+ aggregate_plan_status,
+)
# Only export public entry points; import private helpers from graph_data_* modules.
__all__ = [
@@ -114,11 +123,7 @@ def manage_graph_data(
ErrorType.VALIDATION_ERROR,
f"change_plan is required for mode='{mode}'",
)
- plan = (
- change_plan
- if isinstance(change_plan, dict)
- else _change_plan_from_operations(change_plan)
- )
+ plan = change_plan if isinstance(change_plan, dict) else _change_plan_from_operations(change_plan)
else:
return envelope_err(
ErrorType.VALIDATION_ERROR,
@@ -139,10 +144,55 @@ def manage_graph_data(
if limit_error is not None:
return limit_error
+ isolated_vertex_delete_unavailable = mode == "delete" and any(
+ str(operation.get("op") or operation.get("type")) == "delete_vertex"
+ and operation.get("cascade", False) is False
+ for operation in _operations(plan)
+ )
+ graph_create_unavailable = mode == "import"
+ issued_plan = None
if not dry_run and confirm:
+ violation = guard(Capability.DATA_WRITE)
+ if violation is not None:
+ return violation
+ if isolated_vertex_delete_unavailable:
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "Confirmed isolated vertex deletion is disabled for this backend.",
+ suggestion=(
+ "Delete incident edges explicitly and wait for a backend-proven "
+ "atomic isolated-delete capability before confirming the vertex delete."
+ ),
+ retryable=False,
+ )
+ if graph_create_unavailable:
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "Confirmed graph import is disabled for this backend.",
+ suggestion=(
+ "Use the preview for validation only. Enable import only after "
+ "atomic vertex and edge create-if-absent capabilities are verified."
+ ),
+ retryable=False,
+ )
replay_error = replayed_plan_error(nonce)
if replay_error is not None:
return replay_error
+ issued_plan, issued_plan_error = load_issued_plan(
+ nonce=nonce,
+ plan_hash=plan_hash,
+ expires_at=expires_at,
+ )
+ if issued_plan_error is not None:
+ return issued_plan_error
+ if issued_plan is None:
+ return plan_hash_error(
+ error_type=ErrorType.PLAN_HASH_MISMATCH,
+ details={"reason": "The issued plan has no server-side payload."},
+ mismatch_message="The confirmation predates server-side plan storage.",
+ suggestion="Run dry_run=True again and use the new confirmation plan.",
+ )
+ plan = issued_plan
# Read the live schema before writing. Do not execute without it because
# primary keys, endpoints, and property validity depend on the real schema.
@@ -155,7 +205,7 @@ def manage_graph_data(
retryable=True,
)
- if mode == "import" and graph_data is not None:
+ if mode == "import" and graph_data is not None and issued_plan is None:
# import additionally validates the original graph_data for rules that
# are difficult to express in change_plan: complete vertex keys,
# resolvable edge endpoints, and duplicate identities in the payload.
@@ -185,30 +235,29 @@ def manage_graph_data(
details={"errors": mode_validation["errors"]},
)
- dry_run_result = dry_run_graph_change_plan(
- plan, live_schema, extra_hash_context=extra_hash_context
- )
- if not dry_run_result["valid"]:
- errors = dry_run_result["errors"]
- error_type = next(
- (
- error["error_type"]
- for error in errors
- if isinstance(error, dict) and error.get("error_type")
- ),
- ErrorType.INVALID_GRAPH_DATA,
- )
- return envelope_err(
- error_type,
- "Graph change plan is invalid.",
- details={"errors": errors},
- warnings=dry_run_result.get("warnings", []),
- )
+ if issued_plan is None:
+ dry_run_result = dry_run_graph_change_plan(plan, live_schema, extra_hash_context=extra_hash_context)
+ if not dry_run_result["valid"]:
+ errors = dry_run_result["errors"]
+ error_type = next(
+ (error["error_type"] for error in errors if isinstance(error, dict) and error.get("error_type")),
+ ErrorType.INVALID_GRAPH_DATA,
+ )
+ return envelope_err(
+ error_type,
+ "Graph change plan is invalid.",
+ details={"errors": errors},
+ warnings=dry_run_result.get("warnings", []),
+ )
+ compiled_plan = dry_run_result.pop("compiled_plan", plan)
+ else:
+ compiled_plan = plan
+ dry_run_result = {"valid": True, "warnings": []}
plan_context, _ = _build_manage_graph_data_plan_context(
tool_name=plan_tool_name,
mode=mode,
- plan=plan,
+ plan=compiled_plan,
live_schema=live_schema,
nonce=nonce,
extra_hash_context=extra_hash_context,
@@ -222,23 +271,77 @@ def manage_graph_data(
warnings = list(dry_run_result.get("warnings", []))
next_actions: list[str] = []
if MCPConfig.from_env().is_readonly():
- dry_run_result, readonly_warnings, readonly_next_actions = (
- mark_readonly_preview(
- dry_run_result,
- warning=(
- "This dry-run was generated while HUGEGRAPH_MCP_READONLY=true. "
- "Its plan_hash is preview-only; set HUGEGRAPH_MCP_READONLY=false "
- "and rerun dry_run before confirming writes."
- ),
- next_action="Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm.",
- )
+ dry_run_result, readonly_warnings, readonly_next_actions = mark_readonly_preview(
+ dry_run_result,
+ warning=(
+ "This dry-run was generated while HUGEGRAPH_MCP_READONLY=true. "
+ "Its plan_hash is preview-only; set HUGEGRAPH_MCP_READONLY=false "
+ "and rerun dry_run before confirming writes."
+ ),
+ next_action="Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm.",
)
warnings.extend(readonly_warnings)
next_actions.extend(readonly_next_actions)
- else:
- issue_error = issue_plan(plan_context, target_bound_hash)
+ if isolated_vertex_delete_unavailable or graph_create_unavailable:
+ dry_run_result["confirmable"] = False
+ dry_run_result["preview_only"] = True
+ if isolated_vertex_delete_unavailable:
+ warnings.append(
+ "Isolated vertex deletion is preview-only because the configured "
+ "backend has no verified atomic delete-if-no-incident-edges primitive."
+ )
+ next_actions.append("Delete incident edges explicitly; confirmed vertex deletion remains disabled.")
+ else:
+ try:
+ workflow_preview = compile_graph_write_plan(
+ compiled_plan,
+ plan_context=plan_context,
+ live_schema=live_schema,
+ tool_name=plan_tool_name,
+ )
+ except ValueError as exc:
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "The import cannot be compiled to stable operation identities.",
+ retryable=False,
+ details={"reason": str(exc)},
+ )
+ dry_run_result["workflow_preview"] = {
+ "operations": [operation.to_dict() for operation in workflow_preview.operations]
+ }
+ warnings.append(
+ "Graph import is preview-only because atomic vertex and edge "
+ "create-if-absent capabilities are not verified for this backend."
+ )
+ next_actions.append(
+ "Do not confirm this import until the required backend capabilities pass integration tests."
+ )
+ elif not MCPConfig.from_env().is_readonly():
+ issue_error = issue_plan(
+ plan_context,
+ target_bound_hash,
+ plan_payload=compiled_plan,
+ )
if issue_error is not None:
return issue_error
+ try:
+ canonical_plan = compile_graph_write_plan(
+ compiled_plan,
+ plan_context=plan_context,
+ live_schema=live_schema,
+ tool_name=plan_tool_name,
+ )
+ plan_store_from_config().save_plan(canonical_plan)
+ except Exception as exc: # noqa: BLE001 - fail closed at persistence boundary
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "The graph change cannot be persisted as an executable atomic plan.",
+ retryable=False,
+ details={"reason": str(exc)},
+ )
+ dry_run_result["plan_id"] = canonical_plan.plan_id
+ dry_run_result["status"] = canonical_plan.status.value
+ dry_run_result["expires_at"] = canonical_plan.expires_at
return envelope_ok(
dry_run_result,
warnings=warnings,
@@ -255,16 +358,18 @@ def manage_graph_data(
if not confirm:
return confirm_required_error(
message="Graph data changes require confirm=True after a dry_run.",
- suggestion="Run dry_run=True, review preview and warnings, then pass confirm=True with the returned plan_hash.",
+ suggestion=(
+ "Run dry_run=True, review preview and warnings, then pass confirm=True with the returned plan_hash."
+ ),
)
schema_summary = _schema_summary(live_schema)
- valid, error_type, details = verify_and_consume_plan(
+ valid, error_type, details, consumed_plan = verify_and_consume_issued_plan(
submitted_hash=plan_hash,
tool_name=plan_tool_name,
mode=mode,
payload_digest=_manage_graph_data_payload_digest(
- plan,
+ compiled_plan,
extra_hash_context=extra_hash_context,
),
schema_hash=compute_payload_digest(schema_summary) if schema_summary else None,
@@ -280,9 +385,31 @@ def manage_graph_data(
expired_message="Graph data change plan has expired.",
suggestion="Run dry_run=True again and use the returned plan_hash.",
)
+ if consumed_plan is None:
+ return envelope_err(
+ ErrorType.SERVER_ERROR,
+ "The consumed confirmation did not contain its server-side plan.",
+ retryable=False,
+ )
+ compiled_plan = consumed_plan
- execute_result = execute_graph_change_plan(plan, live_schema=live_schema)
+ execute_result = execute_graph_change_plan(compiled_plan, live_schema=live_schema)
if isinstance(execute_result, dict) and execute_result.get("ok") is False:
+ error_type_value = execute_result["error"]["type"]
+ if error_type_value == ErrorType.WRITE_OUTCOME_UNKNOWN.value:
+ outcome = PlanStatus.UNKNOWN
+ elif error_type_value in {
+ ErrorType.WRITE_CONFLICT.value,
+ "BLOCKED_BY_RELATIONSHIPS",
+ }:
+ outcome = PlanStatus.CONFLICT
+ else:
+ outcome = PlanStatus.REJECTED
+ record_write_outcome(
+ plan_hash=plan_hash,
+ status=outcome.value,
+ receipt=execute_result,
+ )
return envelope_err(
execute_result["error"]["type"],
execute_result["error"]["message"],
@@ -291,23 +418,40 @@ def manage_graph_data(
# have applied an unreported side effect. Require a fresh dry-run
# before any retry instead of encouraging blind replay.
retryable=False,
- details=_normalize_execute_result(execute_result, plan),
+ details=_normalize_execute_result(execute_result, compiled_plan),
)
- normalized = _normalize_execute_result(execute_result, plan)
- if normalized.get("success") is False or normalized.get("status") in {
- "partial",
- "error",
- "degraded",
- }:
+ normalized = _normalize_execute_result(execute_result, compiled_plan)
+ if normalized.get("success") is False:
+ outcome = PlanStatus(normalized["status"])
+ record_write_outcome(
+ plan_hash=plan_hash,
+ status=outcome.value,
+ receipt=normalized,
+ )
+ response_error = ErrorType.PARTIAL_APPLY if outcome is PlanStatus.PARTIAL else ErrorType.FLOW_EXECUTION_FAILED
return envelope_err(
- ErrorType.FLOW_EXECUTION_FAILED,
+ response_error,
"Graph change execution did not complete successfully.",
retryable=bool(normalized.get("retryable")),
details=normalized,
warnings=normalized.get("warnings", []),
)
+ if not record_write_outcome(
+ plan_hash=plan_hash,
+ status=normalized["status"],
+ receipt=normalized,
+ ):
+ return envelope_err(
+ ErrorType.WRITE_OUTCOME_UNKNOWN,
+ "The write completed, but its durable receipt could not be recorded.",
+ retryable=False,
+ details={
+ "status": PlanStatus.UNKNOWN.value,
+ "reconciliation_required": True,
+ },
+ )
return envelope_ok(normalized)
@@ -362,8 +506,9 @@ def _normalize_execute_result(execute_result: Any, plan: Any) -> dict[str, Any]:
planned = _mutation_summary(operations)
if isinstance(execute_result, dict) and execute_result.get("ok") is False:
+ failed_status = _failed_item_status(execute_result["error"])
return {
- "status": "error",
+ "status": aggregate_plan_status([failed_status]).value,
"success": False,
"planned": planned,
"written": {},
@@ -377,7 +522,7 @@ def _normalize_execute_result(execute_result: Any, plan: Any) -> dict[str, Any]:
if not isinstance(execute_result, dict):
return {
- "status": "degraded",
+ "status": PlanStatus.UNKNOWN.value,
"success": False,
"planned": planned,
"written": {},
@@ -395,25 +540,25 @@ def _normalize_execute_result(execute_result: Any, plan: Any) -> dict[str, Any]:
if not isinstance(failed_items, list):
failed_items = []
- if (
- execute_result.get("success") is True
- and len(results) == len(operations)
- and not failed_items
- ):
- status = "success"
- elif results:
- status = "partial"
- elif execute_result.get("success") is False or failed_items:
- status = "error"
+ operation_statuses = [_result_status(result) for result in results]
+ operation_statuses.extend(_failed_item_status(item) for item in failed_items)
+ if operation_statuses:
+ status = aggregate_plan_status(operation_statuses)
+ elif execute_result.get("success") is True and not operations:
+ status = PlanStatus.APPLIED
else:
- status = "degraded"
+ status = PlanStatus.UNKNOWN
written = _mutation_summary(
- [operation for operation, _result in zip(operations, results, strict=False)]
+ [
+ operation
+ for operation, result in zip(operations, results, strict=False)
+ if _result_status(result) is ApplyStatus.APPLIED
+ ]
)
return {
- "status": status,
- "success": status == "success",
+ "status": status.value,
+ "success": status in {PlanStatus.APPLIED, PlanStatus.ALREADY_APPLIED},
"planned": planned,
"written": written,
"failed_items": failed_items,
@@ -421,9 +566,36 @@ def _normalize_execute_result(execute_result: Any, plan: Any) -> dict[str, Any]:
"retryable": False,
"compensation_suggestions": (
["Inspect graph state before retrying remaining operations."]
- if status in ("partial", "degraded")
+ if status in {PlanStatus.PARTIAL, PlanStatus.UNKNOWN}
else []
),
"results": results,
"mutation_summary": execute_result.get("mutation_summary", planned),
}
+
+
+def _result_status(result: dict[str, Any]) -> ApplyStatus:
+ try:
+ return ApplyStatus(result.get("status", ApplyStatus.APPLIED.value))
+ except ValueError:
+ return ApplyStatus.UNKNOWN
+
+
+def _failed_item_status(item: dict[str, Any]) -> ApplyStatus:
+ raw_status = item.get("status")
+ if raw_status is not None:
+ try:
+ return ApplyStatus(raw_status)
+ except ValueError:
+ return ApplyStatus.UNKNOWN
+
+ error = item.get("error") if isinstance(item.get("error"), dict) else item
+ error_type = error.get("type")
+ if error_type == ErrorType.WRITE_OUTCOME_UNKNOWN.value:
+ return ApplyStatus.UNKNOWN
+ if error_type in {
+ ErrorType.WRITE_CONFLICT.value,
+ "BLOCKED_BY_RELATIONSHIPS",
+ }:
+ return ApplyStatus.CONFLICT
+ return ApplyStatus.REJECTED
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py
index 7046a068..2f4252ad 100644
--- a/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py
+++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py
@@ -30,6 +30,7 @@ from hugegraph_mcp.confirmable_workflow import (
confirm_required_error,
issue_plan,
plan_hash_error,
+ record_write_outcome,
replayed_plan_error,
verify_and_consume_plan,
)
@@ -42,6 +43,7 @@ from hugegraph_mcp.plan_hash import (
compute_payload_digest,
compute_plan_hash,
)
+from hugegraph_mcp.plan_store import plan_store_from_config
from hugegraph_mcp.tools.live_schema import current_live_schema
from hugegraph_mcp.tools.schema_contract import (
SUPPORTED_FIELDS,
@@ -54,11 +56,13 @@ from hugegraph_mcp.tools.schema_utils import (
schema_payload,
user_data_without_server_metadata,
)
+from hugegraph_mcp.tools.schema_write_adapter import compile_schema_write_plan
from hugegraph_mcp.write_limits import (
collect_write_limit_errors,
operation_count_from_list,
write_limit_envelope,
)
+from hugegraph_mcp.write_plan import ApplyReceipt, ApplyStatus, PlanStatus
ALLOWED_OPERATION_TYPES = frozenset(
{
@@ -90,9 +94,7 @@ IDENTIFIER_FIELDS = {
"create_index_label": ("name", "base_label"),
}
-UNSUPPORTED_EDGE_LABEL_FIELDS = frozenset(
- {"parent_label", "parentLabel", "edgelabel_type", "edgeLabelType"}
-)
+UNSUPPORTED_EDGE_LABEL_FIELDS = frozenset({"parent_label", "parentLabel", "edgelabel_type", "edgeLabelType"})
PROPERTY_KEY_DATA_TYPES = frozenset(
{
@@ -111,12 +113,8 @@ PROPERTY_KEY_DATA_TYPES = frozenset(
}
)
PROPERTY_KEY_CARDINALITIES = frozenset({"SINGLE", "SET", "LIST"})
-PROPERTY_KEY_AGGREGATE_TYPES = frozenset(
- {"NONE", "OLD", "SUM", "MIN", "MAX", "SET", "LIST"}
-)
-VERTEX_LABEL_ID_STRATEGIES = frozenset(
- {"PRIMARY_KEY", "CUSTOMIZE_STRING", "CUSTOMIZE_NUMBER", "AUTOMATIC"}
-)
+PROPERTY_KEY_AGGREGATE_TYPES = frozenset({"NONE", "OLD", "SUM", "MIN", "MAX", "SET", "LIST"})
+VERTEX_LABEL_ID_STRATEGIES = frozenset({"PRIMARY_KEY", "CUSTOMIZE_STRING", "CUSTOMIZE_NUMBER", "AUTOMATIC"})
EDGE_LABEL_FREQUENCIES = frozenset({"SINGLE", "MULTIPLE"})
PROPERTY_KEY_DATA_TYPE_METHODS = {
@@ -212,10 +210,7 @@ def _validate_supported_fields(
unknown = [
field
for field in operation
- if field not in supported
- and not (
- op_type == "create_edge_label" and field in UNSUPPORTED_EDGE_LABEL_FIELDS
- )
+ if field not in supported and not (op_type == "create_edge_label" and field in UNSUPPORTED_EDGE_LABEL_FIELDS)
]
if not unknown:
return
@@ -275,9 +270,7 @@ def _validate_index_labels_field(
if "index_labels" not in operation:
return
values = operation["index_labels"]
- if not isinstance(values, list) or any(
- not isinstance(value, str) or not value for value in values
- ):
+ if not isinstance(values, list) or any(not isinstance(value, str) or not value for value in values):
errors.append(
_validation_error(
idx,
@@ -294,8 +287,7 @@ def _validate_index_labels_field(
_validation_error(
idx,
operation,
- "index_labels contains duplicate name(s): "
- + ", ".join(duplicate_names),
+ "index_labels contains duplicate name(s): " + ", ".join(duplicate_names),
"Remove duplicate index label names from index_labels.",
)
)
@@ -364,10 +356,7 @@ def _collect_planned_creates(
idx,
operation,
f"duplicate {op_type} name {name} within the same batch",
- (
- f"Define each {create_type_to_label[op_type]} only once "
- "per schema operation batch."
- ),
+ (f"Define each {create_type_to_label[op_type]} only once per schema operation batch."),
)
)
continue
@@ -579,14 +568,8 @@ def _validate_property_key_aggregate_cardinality(
_validation_error(
idx,
operation,
- (
- f"aggregate_type {aggregate_type!r} is not allowed with "
- f"cardinality {cardinality!r}"
- ),
- (
- f"Use cardinality in {sorted(allowed_cardinalities)} for "
- f"aggregate_type {aggregate_type!r}."
- ),
+ (f"aggregate_type {aggregate_type!r} is not allowed with cardinality {cardinality!r}"),
+ (f"Use cardinality in {sorted(allowed_cardinalities)} for aggregate_type {aggregate_type!r}."),
)
)
@@ -682,8 +665,7 @@ def _validate_vertex_primary_keys(
_validation_error(
idx,
operation,
- "primary_keys must be included in properties: "
- + ", ".join(missing_from_properties),
+ "primary_keys must be included in properties: " + ", ".join(missing_from_properties),
"Add each primary key to properties before using it as a primary key.",
)
)
@@ -694,8 +676,7 @@ def _validate_vertex_primary_keys(
_validation_error(
idx,
operation,
- "primary_keys references undefined property key(s): "
- + ", ".join(missing_property_keys),
+ "primary_keys references undefined property key(s): " + ", ".join(missing_property_keys),
"Create these property keys first and rerun validation after they exist in the live schema.",
)
)
@@ -726,9 +707,7 @@ def _validation_warnings(operations: list[dict[str, Any]]) -> list[str]:
and id_strategy != "PRIMARY_KEY"
and not operation.get("primary_keys")
):
- warnings.append(
- f"operation {idx} (create_vertex_label) has no primary_keys definition"
- )
+ warnings.append(f"operation {idx} (create_vertex_label) has no primary_keys definition")
return warnings
@@ -843,16 +822,11 @@ def validate_schema_operations(
f"Add {field} to the {op_type} operation.",
)
)
- if any(
- field not in operation or operation[field] in (None, "")
- for field in REQUIRED_FIELDS[op_type]
- ):
+ if any(field not in operation or operation[field] in (None, "") for field in REQUIRED_FIELDS[op_type]):
continue
identifier_ok = all(
- _validate_identifier_field(
- idx=idx, operation=operation, field=field, errors=errors
- )
+ _validate_identifier_field(idx=idx, operation=operation, field=field, errors=errors)
for field in IDENTIFIER_FIELDS.get(op_type, ())
)
if not identifier_ok:
@@ -954,22 +928,14 @@ def validate_schema_operations(
"Use a new edge label name or remove this create_edge_label operation.",
)
)
- unsupported_fields = sorted(
- field for field in UNSUPPORTED_EDGE_LABEL_FIELDS if field in operation
- )
+ unsupported_fields = sorted(field for field in UNSUPPORTED_EDGE_LABEL_FIELDS if field in operation)
if unsupported_fields:
errors.append(
_validation_error(
idx,
operation,
- (
- "unsupported parent/sub edge label field(s): "
- f"{', '.join(unsupported_fields)}"
- ),
- (
- "Parent/sub edge labels are not supported by manage_schema; "
- "remove these fields."
- ),
+ (f"unsupported parent/sub edge label field(s): {', '.join(unsupported_fields)}"),
+ ("Parent/sub edge labels are not supported by manage_schema; remove these fields."),
)
)
for field in ("source_label", "target_label"):
@@ -1062,15 +1028,9 @@ def validate_schema_operations(
errors=errors,
)
- if (
- op_type == "create_property_key"
- and name in planned_creates["property_keys"]
- ):
+ if op_type == "create_property_key" and name in planned_creates["property_keys"]:
available_property_keys.add(name)
- elif (
- op_type == "create_vertex_label"
- and name in planned_creates["vertex_labels"]
- ):
+ elif op_type == "create_vertex_label" and name in planned_creates["vertex_labels"]:
available_vertex_labels.add(name)
elif op_type == "create_edge_label" and name in planned_creates["edge_labels"]:
available_edge_labels.add(name)
@@ -1112,9 +1072,7 @@ def _build_schema_plan_context(
return context
-def calculate_plan_hash(
- operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None
-) -> str:
+def calculate_plan_hash(operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None) -> str:
"""Compatibility wrapper backed by the unified target-bound PlanContext."""
return compute_plan_hash(
@@ -1138,9 +1096,7 @@ def _plan_context_payload(plan_context: PlanContext) -> dict[str, Any]:
}
-def _risk_warnings(
- operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None
-) -> list[str]:
+def _risk_warnings(operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None) -> list[str]:
warnings: list[str] = []
live_schema = current_live_schema(live_schema)
property_keys = _schema_items(live_schema, "propertykeys")
@@ -1152,9 +1108,7 @@ def _risk_warnings(
created_vertex_labels = planned_creates["vertex_labels"]
created_edge_labels = planned_creates["edge_labels"]
indexed_labels = {
- op.get("base_label")
- for op in operations
- if op.get("type") == "create_index_label" and op.get("base_label")
+ op.get("base_label") for op in operations if op.get("type") == "create_index_label" and op.get("base_label")
}
for operation in operations:
@@ -1218,10 +1172,7 @@ def _safe_fetch_live_schema() -> tuple[dict[str, Any] | None, dict[str, Any] | N
return None, envelope_err(
ErrorType.CONNECTION_FAILED,
"Cannot read live schema from HugeGraph Server for schema validation.",
- suggestion=(
- "Ensure HugeGraph Server is running and credentials/graphspace are "
- "correct, then retry."
- ),
+ suggestion=("Ensure HugeGraph Server is running and credentials/graphspace are correct, then retry."),
retryable=True,
details={"stage": "schema_fetch", "error": str(exc)},
)
@@ -1246,6 +1197,19 @@ def dry_run_schema_operations(
validation = validate_schema_operations(operations, live_schema)
if not validation["valid"]:
return validation
+ if len(operations) != 1:
+ return {
+ "valid": False,
+ "errors": [
+ _validation_error(
+ -1,
+ operations,
+ "a confirmable schema plan must contain exactly one create operation",
+ "Create and confirm one schema object per plan.",
+ )
+ ],
+ "warnings": validation.get("warnings", []),
+ }
apply_scope_errors = _validate_apply_scope(operations)
if apply_scope_errors:
return {
@@ -1266,8 +1230,7 @@ def dry_run_schema_operations(
"plan_context": _plan_context_payload(plan_context),
"confirmable": True,
"mutation_summary": _mutation_summary(operations),
- "warnings": validation.get("warnings", [])
- + _risk_warnings(operations, live_schema),
+ "warnings": validation.get("warnings", []) + _risk_warnings(operations, live_schema),
}
@@ -1284,8 +1247,11 @@ def _design_from_operations(operations: list[dict[str, Any]]) -> dict[str, Any]:
def _schema_manager():
+ cfg = MCPConfig.from_env()
return build_hugegraph_client(
- MCPConfig.from_env(), client_cls=PyHugeClient
+ cfg,
+ client_cls=PyHugeClient,
+ request_timeout_seconds=cfg.write_timeout_seconds,
).schema()
@@ -1295,58 +1261,89 @@ def apply_schema_operations(
live_schema: dict[str, Any],
stop_on_first_error: bool = True,
) -> dict[str, Any]:
- """Apply P0a create operations and verify each operation by post-read schema."""
+ """Apply exactly one schema create and report ambiguous outcomes explicitly."""
- manager = _schema_manager()
- applied_operations: list[dict[str, Any]] = []
- operation_results: list[dict[str, Any]] = []
-
- for idx, operation in enumerate(operations):
- try:
- _apply_one_operation(manager, operation)
- observed_schema = current_live_schema()
- except Exception as exc: # noqa: BLE001 - preserve partial-apply result
- return _partial_apply_result(
- operations=operations,
- applied_operations=applied_operations,
- operation_results=operation_results,
- failed_operation=operation,
- failed_operation_index=idx,
- error=str(exc),
- )
+ if len(operations) != 1:
+ raise ValueError("A schema apply plan must contain exactly one operation")
- if not _operation_observed(operation, observed_schema):
- failed = _partial_apply_result(
- operations=operations,
- applied_operations=applied_operations,
- operation_results=operation_results,
- failed_operation=operation,
- failed_operation_index=idx,
- error="post-read schema did not contain the created object",
- )
- if stop_on_first_error:
- return failed
-
- applied_operations.append(operation)
- operation_results.append(
- {
- "operation_index": idx,
- "operation": operation,
- "status": "applied",
- }
- )
- live_schema = observed_schema
+ operation = operations[0]
+ existing_state, existing_object = _schema_object_state(operation, live_schema)
+ if existing_state == "identical":
+ return ApplyReceipt(
+ status=ApplyStatus.ALREADY_APPLIED,
+ operation=operation,
+ observed_state=existing_object,
+ reconciliation_required=False,
+ ).to_dict()
+ if existing_state == "conflict":
+ return ApplyReceipt(
+ status=ApplyStatus.CONFLICT,
+ operation=operation,
+ observed_state=existing_object,
+ reason="a different schema object already exists with the confirmed name",
+ reconciliation_required=False,
+ ).to_dict()
- return {
- "status": "applied",
- "valid": True,
- "applied_operations": applied_operations,
- "operation_results": operation_results,
- "mutation_summary": _mutation_summary(applied_operations),
- "schema_summary": normalized_schema_summary(
- live_schema, include_user_data=True
- ),
- }
+ failure_stage = "schema create"
+ try:
+ # Client/manager construction belongs to the write attempt: once a
+ # confirmation has been consumed, any failure through verification
+ # must yield a durable, reconcilable outcome instead of escaping and
+ # leaving the plan indefinitely EXECUTING.
+ manager = _schema_manager()
+ _apply_one_operation(manager, operation)
+ failure_stage = "post-read schema"
+ observed_schema = current_live_schema()
+ except Exception as exc: # noqa: BLE001 - request may already have committed
+ return ApplyReceipt(
+ status=ApplyStatus.UNKNOWN,
+ operation=operation,
+ reason=f"{failure_stage} failed: {exc}",
+ reconciliation_required=True,
+ ).to_dict()
+
+ observed_state, observed_object = _schema_object_state(operation, observed_schema)
+ if observed_state == "conflict":
+ return ApplyReceipt(
+ status=ApplyStatus.CONFLICT,
+ operation=operation,
+ observed_state=observed_object,
+ reason="post-read found a different schema object with the confirmed name",
+ reconciliation_required=False,
+ ).to_dict()
+ if observed_state != "identical":
+ return ApplyReceipt(
+ status=ApplyStatus.UNKNOWN,
+ operation=operation,
+ observed_state=normalized_schema_summary(observed_schema, include_user_data=True),
+ reason="post-read schema did not match the confirmed object",
+ reconciliation_required=True,
+ ).to_dict()
+
+ receipt = ApplyReceipt(
+ status=ApplyStatus.APPLIED,
+ operation=operation,
+ observed_state=observed_object,
+ reconciliation_required=False,
+ ).to_dict()
+ # Preserve the existing public success fields for one compatibility cycle;
+ # their status is sourced exclusively from the canonical receipt.
+ receipt.update(
+ {
+ "valid": True,
+ "applied_operations": operations,
+ "operation_results": [
+ {
+ "operation_index": 0,
+ "operation": operation,
+ "status": receipt["status"],
+ }
+ ],
+ "mutation_summary": _mutation_summary(operations),
+ "schema_summary": normalized_schema_summary(observed_schema, include_user_data=True),
+ }
+ )
+ return receipt
def _apply_one_operation(manager, operation: dict[str, Any]) -> None:
@@ -1354,9 +1351,7 @@ def _apply_one_operation(manager, operation: dict[str, Any]) -> None:
field_specs = field_specs_for_operation(op_type)
apply_fields = apply_fields_for_operation(op_type)
if not field_specs or any(field not in apply_fields for field in operation):
- raise ValueError(
- f"Unsupported or non-forwardable schema field(s) for {op_type}"
- )
+ raise ValueError(f"Unsupported or non-forwardable schema field(s) for {op_type}")
if op_type == "create_property_key":
builder = manager.propertyKey(operation["name"])
_apply_property_key_options(builder, operation)
@@ -1401,9 +1396,7 @@ def _apply_property_key_options(builder, operation: dict[str, Any]) -> None:
else:
method_name = PROPERTY_KEY_AGGREGATE_METHODS.get(normalized)
if method_name is None:
- raise ValueError(
- f"Unsupported property key aggregate_type: {aggregate_type}"
- )
+ raise ValueError(f"Unsupported property key aggregate_type: {aggregate_type}")
getattr(builder, method_name)()
_forward_parameter_fields(
@@ -1426,9 +1419,7 @@ def _set_builder_parameter(
raise ValueError(f"Unsupported {op_type} field: {field}")
setter = getattr(builder, "add_parameter", None)
if not callable(setter):
- raise TypeError(
- f"Schema builder cannot forward supported {op_type} field: {field}"
- )
+ raise TypeError(f"Schema builder cannot forward supported {op_type} field: {field}")
setter(field, value)
@@ -1442,11 +1433,7 @@ def _forward_parameter_fields(
"""Forward every table-declared parameter field present in an operation."""
for field, spec in field_specs_for_operation(op_type).items():
- if (
- field in operation
- and field not in exclude
- and spec.apply_mode == "parameter"
- ):
+ if field in operation and field not in exclude and spec.apply_mode == "parameter":
_set_builder_parameter(builder, op_type, field, operation[field])
@@ -1466,10 +1453,7 @@ def _apply_vertex_label_options(builder, operation: dict[str, Any]) -> None:
if "enable_label_index" in operation:
method = getattr(builder, "enableLabelIndex", None)
if not callable(method):
- raise ValueError(
- "Schema builder cannot forward supported create_vertex_label field: "
- "enable_label_index"
- )
+ raise ValueError("Schema builder cannot forward supported create_vertex_label field: enable_label_index")
method(operation["enable_label_index"])
_forward_parameter_fields(builder, "create_vertex_label", operation)
@@ -1493,10 +1477,7 @@ def _apply_edge_label_options(builder, operation: dict[str, Any]) -> None:
if "enable_label_index" in operation:
method = getattr(builder, "enableLabelIndex", None)
if not callable(method):
- raise ValueError(
- "Schema builder cannot forward supported create_edge_label field: "
- "enable_label_index"
- )
+ raise ValueError("Schema builder cannot forward supported create_edge_label field: enable_label_index")
method(operation["enable_label_index"])
_forward_parameter_fields(builder, "create_edge_label", operation)
@@ -1505,14 +1486,22 @@ def _operation_observed(
operation: dict[str, Any],
live_schema: dict[str, Any] | None,
) -> bool:
- schema = (
- (live_schema or {}).get("schema") if isinstance(live_schema, dict) else None
- )
+ state, _ = _schema_object_state(operation, live_schema)
+ return state == "identical"
+
+
+def _schema_object_state(
+ operation: dict[str, Any],
+ live_schema: dict[str, Any] | None,
+) -> tuple[str, dict[str, Any] | None]:
+ """Classify a schema object's name-bound state for create/reconcile."""
+
+ schema = (live_schema or {}).get("schema") if isinstance(live_schema, dict) else None
if not isinstance(schema, dict):
schema = live_schema if isinstance(live_schema, dict) else {}
collection = schema_collection_for_operation(_operation_type(operation))
if collection is None:
- return False
+ return "missing", None
name_spec = field_specs_for_operation(_operation_type(operation)).get("name")
observed = _find_schema_item(
@@ -1522,8 +1511,10 @@ def _operation_observed(
name_aliases=name_spec.aliases if name_spec else ("name",),
)
if observed is None:
- return False
- return _operation_fields_match(operation, observed)
+ return "missing", None
+ if _operation_fields_match(operation, observed):
+ return "identical", observed
+ return "conflict", observed
def _find_schema_item(
@@ -1615,9 +1606,7 @@ def _enum_field_matches(
)
if observed_value is None:
return False
- return _canonical_enum_value(field, observed_value) == _canonical_enum_value(
- field, str(expected).upper()
- )
+ return _canonical_enum_value(field, observed_value) == _canonical_enum_value(field, str(expected).upper())
def _string_field_matches(
@@ -1628,10 +1617,7 @@ def _string_field_matches(
) -> bool:
if field not in operation:
return True
- return (
- _field_value(observed, *(aliases or (field, _camel_case_schema_field(field))))
- == operation[field]
- )
+ return _field_value(observed, *(aliases or (field, _camel_case_schema_field(field)))) == operation[field]
def _mapping_field_matches(
@@ -1643,9 +1629,7 @@ def _mapping_field_matches(
if field not in operation:
return True
expected = operation[field]
- actual = _field_value(
- observed, *(aliases or (field, _camel_case_schema_field(field), "userdata"))
- )
+ actual = _field_value(observed, *(aliases or (field, _camel_case_schema_field(field), "userdata")))
# HugeGraph may omit an empty optional user_data object in its response.
if actual is None and expected == {}:
return True
@@ -1665,10 +1649,7 @@ def _scalar_field_matches(
) -> bool:
if field not in operation:
return True
- return (
- _field_value(observed, *(aliases or (field, _camel_case_schema_field(field))))
- == operation[field]
- )
+ return _field_value(observed, *(aliases or (field, _camel_case_schema_field(field)))) == operation[field]
def _camel_case_schema_field(field: str) -> str:
@@ -1690,9 +1671,7 @@ def _supported_field_matches(
if spec.kind == "enum":
default = None
default = spec.default
- return _enum_field_matches(
- observed, operation, field, default=default, aliases=aliases
- )
+ return _enum_field_matches(observed, operation, field, default=default, aliases=aliases)
if spec.kind == "list":
return _list_field_matches(observed, operation, field, aliases)
if spec.kind == "mapping":
@@ -1705,9 +1684,7 @@ def _supported_field_matches(
return field in {"type", "name"}
-def _operation_fields_match(
- operation: dict[str, Any], observed: dict[str, Any]
-) -> bool:
+def _operation_fields_match(operation: dict[str, Any], observed: dict[str, Any]) -> bool:
op_type = _operation_type(operation)
if op_type not in SUPPORTED_FIELDS:
return False
@@ -1719,56 +1696,15 @@ def _operation_fields_match(
return False
fields = {
- field
- for field in field_specs_for_operation(op_type)
- if field in operation and field not in {"type", "name"}
+ field for field in field_specs_for_operation(op_type) if field in operation and field not in {"type", "name"}
}
# These options have always had an apply-time default. Keep checking the
# effective value even when the caller omitted the optional field.
for field, spec in field_specs_for_operation(op_type).items():
- if (
- field not in {"type", "name"}
- and field not in operation
- and spec.default is not None
- ):
+ if field not in {"type", "name"} and field not in operation and spec.default is not None:
fields.add(field)
- return all(
- _supported_field_matches(op_type, field, operation, observed)
- for field in fields
- )
-
-
-def _partial_apply_result(
- *,
- operations: list[dict[str, Any]],
- applied_operations: list[dict[str, Any]],
- operation_results: list[dict[str, Any]],
- failed_operation: dict[str, Any],
- failed_operation_index: int,
- error: str,
-) -> dict[str, Any]:
- remaining_operations = operations[failed_operation_index:]
- return {
- "status": "partial" if applied_operations else "failed",
- "valid": False,
- "applied_operations": applied_operations,
- "operation_results": operation_results,
- "failed_operation": failed_operation,
- "failed_operation_index": failed_operation_index,
- "error": error,
- "remaining_operations": remaining_operations,
- "recovery_suggestions": _recovery_suggestions(),
- "mutation_summary": _mutation_summary(applied_operations),
- }
-
-
-def _recovery_suggestions() -> list[str]:
- return [
- "Call inspect_schema_tool to observe the current schema state.",
- "Remove already-applied operations and dry-run the remaining operations again.",
- "Do not retry the original full batch without checking which operations were applied.",
- ]
+ return all(_supported_field_matches(op_type, field, operation, observed) for field in fields)
def manage_schema(
@@ -1831,6 +1767,23 @@ def manage_schema(
issue_error = issue_plan(plan_context, result["plan_hash"])
if issue_error is not None:
return issue_error
+ try:
+ canonical_plan = compile_schema_write_plan(
+ operations[0],
+ plan_context=plan_context,
+ live_schema=live_schema,
+ )
+ plan_store_from_config().save_plan(canonical_plan)
+ except Exception as exc: # noqa: BLE001 - fail closed at persistence boundary
+ return envelope_err(
+ ErrorType.SERVER_ERROR,
+ "The schema create plan could not be persisted safely.",
+ retryable=False,
+ details={"reason": type(exc).__name__},
+ )
+ result["plan_id"] = canonical_plan.plan_id
+ result["status"] = canonical_plan.status.value
+ result["expires_at"] = canonical_plan.expires_at
if result.get("valid") and MCPConfig.from_env().is_readonly():
result["confirmable"] = False
result["readonly_preview_only"] = True
@@ -1843,15 +1796,22 @@ def manage_schema(
"Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirming writes."
)
],
- next_actions=[
- "Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm."
- ],
+ next_actions=["Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm."],
)
return envelope_ok(
result,
- next_actions=[
- "Review schema preview, then call apply_schema_tool(mode='apply', confirm=true, plan_hash, nonce, expires_at)."
- ]
+ warnings=list(result.get("warnings", []))
+ + (
+ [
+ (
+ "LEGACY_CONFIRMATION_DEPRECATED: use plan_id with confirm_write_tool; "
+ "plan_hash, nonce, and expires_at will be removed after one release."
+ )
+ ]
+ if result.get("valid") and result.get("plan_id")
+ else []
+ ),
+ next_actions=["Review schema preview, then call confirm_write_tool with plan_id."]
if result.get("valid")
else ["Fix validation errors and rerun apply_schema_tool(mode='dry_run')."],
)
@@ -1871,9 +1831,7 @@ def manage_schema(
"Schema operations are not valid for P0a apply.",
details={"errors": dry_run_result.get("errors", [])},
warnings=dry_run_result.get("warnings", []),
- next_actions=[
- "Fix validation errors and rerun apply_schema_tool(mode='dry_run')."
- ],
+ next_actions=["Fix validation errors and rerun apply_schema_tool(mode='dry_run')."],
)
violation = guard(Capability.SCHEMA_WRITE)
if violation is not None:
@@ -1882,8 +1840,7 @@ def manage_schema(
return confirm_required_error(
message="Schema apply requires confirm=True after dry_run.",
suggestion=(
- "Run mode='dry_run', review the plan, then pass confirm=True "
- "with plan_hash, nonce, and expires_at."
+ "Run mode='dry_run', review the plan, then pass confirm=True with plan_hash, nonce, and expires_at."
),
)
valid, error_type, details = verify_and_consume_plan(
@@ -1904,15 +1861,76 @@ def manage_schema(
)
apply_result = apply_schema_operations(operations, live_schema=live_schema)
- if apply_result.get("status") in {"failed", "partial"}:
+ try:
+ result_status = ApplyStatus(apply_result["status"])
+ except (KeyError, TypeError, ValueError) as exc:
+ apply_result = ApplyReceipt(
+ status=ApplyStatus.UNKNOWN,
+ operation=operations[0],
+ reason=f"schema executor returned a non-canonical receipt: {exc}",
+ reconciliation_required=True,
+ ).to_dict()
+ result_status = ApplyStatus.UNKNOWN
+ if result_status is ApplyStatus.UNKNOWN:
+ record_write_outcome(
+ plan_hash=plan_hash,
+ status=PlanStatus.UNKNOWN,
+ receipt=apply_result,
+ )
+ return envelope_err(
+ ErrorType.WRITE_OUTCOME_UNKNOWN,
+ "The schema create may have committed; reconcile before retrying.",
+ suggestion="Inspect the schema and compare it with the confirmed operation before issuing another write.",
+ retryable=False,
+ details=apply_result,
+ next_actions=["Call inspect_schema_tool and reconcile the confirmed schema object."],
+ )
+ if result_status is ApplyStatus.CONFLICT:
+ if not record_write_outcome(
+ plan_hash=plan_hash,
+ status=PlanStatus.CONFLICT,
+ receipt=apply_result,
+ ):
+ return envelope_err(
+ ErrorType.WRITE_OUTCOME_UNKNOWN,
+ "The schema conflict was observed, but its durable receipt could not be recorded.",
+ retryable=False,
+ details={
+ "status": ApplyStatus.UNKNOWN.value,
+ "reconciliation_required": True,
+ },
+ )
return envelope_err(
- ErrorType.PARTIAL_APPLY,
- "Schema apply did not complete all operations.",
+ ErrorType.WRITE_CONFLICT,
+ "A different schema object exists with the confirmed name.",
+ retryable=False,
details=apply_result,
- next_actions=apply_result.get("recovery_suggestions", []),
+ )
+ persisted_status = (
+ PlanStatus.ALREADY_APPLIED if result_status is ApplyStatus.ALREADY_APPLIED else PlanStatus.APPLIED
+ )
+ if not record_write_outcome(
+ plan_hash=plan_hash,
+ status=persisted_status,
+ receipt=apply_result,
+ ):
+ return envelope_err(
+ ErrorType.WRITE_OUTCOME_UNKNOWN,
+ "The schema create completed, but its durable receipt could not be recorded.",
+ retryable=False,
+ details={
+ "status": ApplyStatus.UNKNOWN.value,
+ "reconciliation_required": True,
+ },
)
return envelope_ok(
apply_result,
+ warnings=[
+ (
+ "LEGACY_CONFIRMATION_DEPRECATED: use plan_id with confirm_write_tool; "
+ "plan_hash, nonce, and expires_at will be removed after one release."
+ )
+ ],
next_actions=["Call inspect_schema_tool to verify the applied schema."],
)
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py b/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py
index f3ba6615..44ce88d1 100644
--- a/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py
+++ b/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py
@@ -18,20 +18,20 @@ from uuid import uuid4
from pyhugegraph.client import PyHugeClient
+from hugegraph_mcp.backend_capabilities import (
+ BackendFeature,
+ CapabilityEvidence,
+ profile_for,
+)
from hugegraph_mcp.config import MCPConfig
from hugegraph_mcp.confirmable_workflow import (
confirm_required_error,
- issue_plan,
mark_readonly_preview,
- plan_hash_error,
- replayed_plan_error,
- verify_and_consume_plan,
)
from hugegraph_mcp.envelope import (
ErrorType,
envelope_err,
envelope_ok,
- sanitize_for_response,
)
from hugegraph_mcp.error_mapping import classify_hugegraph_exception
from hugegraph_mcp.guard import Capability, guard
@@ -42,12 +42,14 @@ from hugegraph_mcp.plan_hash import (
compute_plan_hash,
)
from hugegraph_mcp.tools.live_schema import current_live_schema
+from hugegraph_mcp.tools.property_validation import property_specs, property_value_error
from hugegraph_mcp.tools.schema_utils import (
normalized_schema_summary,
property_cardinalities,
property_names,
schema_payload,
)
+from hugegraph_mcp.write_plan import ApplyStatus, PlanStatus, aggregate_plan_status
TARGETS = frozenset({"vertex", "edge"})
OPERATIONS = frozenset({"append", "eliminate"})
@@ -80,8 +82,9 @@ def mutate_graph_properties(
The dry-run reads live schema and the target object, validates property keys
against the target label, previews before/after state, and binds a target
- snapshot digest into plan_hash. Confirm re-reads schema and target; if either
- changed, it returns TARGET_CHANGED or PLAN_HASH_MISMATCH before writing.
+ snapshot digest into plan_hash. Applying a confirmed mutation is disabled
+ until HugeGraph exposes a backend-enforced conditional property update. The
+ current append/eliminate REST endpoints cannot provide atomic compare-and-set.
"""
validation_error = _validate_inputs(
@@ -93,11 +96,6 @@ def mutate_graph_properties(
if validation_error is not None:
return validation_error
- if not dry_run and confirm:
- replay_error = replayed_plan_error(nonce, source="mutate_graph_properties_tool")
- if replay_error is not None:
- return replay_error
-
live_schema, target_item, read_error = _read_schema_and_target(target=target, id=id)
if read_error is not None:
return read_error
@@ -144,15 +142,20 @@ def mutate_graph_properties(
"after": preview["after"],
"plan_hash": plan_hash_value,
"plan_context": _plan_context_payload(plan_context),
- "status": "planned",
- "confirmable": True,
+ "status": PlanStatus.ISSUED.value,
+ "confirmable": False,
+ "cas_request": {
+ "target_type": target,
+ "target_id": id,
+ "expected_properties": preview["before"].get("properties", {}),
+ "desired_properties": preview["after"].get("properties", {}),
+ "operation_id": f"property-cas:{plan_hash_value[:24]}",
+ },
}
if dry_run:
- warnings: list[str] = []
- next_actions = [
- "Review before/after, then call mutate_graph_properties_tool with dry_run=false, confirm=true, plan_hash, nonce, and expires_at.",
- ]
+ warnings = ["Preview only: HugeGraph does not expose an atomic conditional property update."]
+ next_actions = ["Enable confirmation only after the backend provides compare-and-set for property replacement."]
if MCPConfig.from_env().is_readonly():
payload, warnings, next_actions = mark_readonly_preview(
payload,
@@ -162,10 +165,6 @@ def mutate_graph_properties(
),
next_action="Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm.",
)
- else:
- issue_error = issue_plan(plan_context, plan_hash_value)
- if issue_error is not None:
- return issue_error
return envelope_ok(payload, warnings=warnings, next_actions=next_actions)
violation = guard(Capability.DATA_WRITE)
@@ -176,62 +175,38 @@ def mutate_graph_properties(
return confirm_required_error(
message="Property mutations require confirm=True after dry_run.",
suggestion=(
- "Run dry_run=True, review the preview, then pass confirm=True "
- "with plan_hash, nonce, and expires_at."
+ "Run dry_run=True, review the preview, then pass confirm=True with plan_hash, nonce, and expires_at."
),
source="mutate_graph_properties_tool",
)
- expected_snapshot = _snapshot_from_nonce(nonce)
- if (
- expected_snapshot is not None
- and expected_snapshot != preview["target_snapshot_digest"][:16]
- ):
- return envelope_err(
- ErrorType.TARGET_CHANGED,
- "Target changed since dry_run; property mutation was not applied.",
- suggestion="Run dry_run=True again and review the new before/after preview.",
- source="mutate_graph_properties_tool",
- details={
- "expected_target_snapshot_digest_prefix": expected_snapshot,
- "current_target_snapshot_digest": preview["target_snapshot_digest"],
- },
- next_actions=["Call query_graph_data_tool to inspect the current target."],
- )
-
- valid, error_type, details = verify_and_consume_plan(
- submitted_hash=plan_hash,
- tool_name="mutate_graph_properties_tool",
- mode="mutate",
- payload_digest=_payload_digest(target, operation, id, properties),
- schema_hash=_schema_hash(live_schema),
- nonce=nonce,
- expires_at=expires_at,
- extra_context={
+ evidence = _property_cas_evidence()
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "Confirmed property mutation is disabled because HugeGraph does not expose atomic compare-and-set updates.",
+ suggestion=(
+ "Add a backend-enforced conditional property replacement API that compares "
+ "the expected state and writes the desired state in one atomic operation."
+ ),
+ source="mutate_graph_properties_tool",
+ details={
"target": target,
- "operation": operation,
- "target_snapshot_digest": preview["target_snapshot_digest"],
+ "id": id,
+ "status": aggregate_plan_status([ApplyStatus.REJECTED]).value,
+ "required_capability": "conditional_property_compare_and_set",
+ "backend_capability": BackendFeature.PROPERTY_COMPARE_AND_SET.value,
+ "capability_status": evidence.status.value,
+ "capability_evidence": evidence.reason,
+ "write_attempted": False,
},
+ next_actions=["Keep this plan as a preview; do not retry confirmation until backend CAS is available."],
)
- if not valid:
- return plan_hash_error(
- error_type=error_type,
- details=details,
- mismatch_message="Provided plan_hash does not match the current property mutation plan.",
- suggestion="Run dry_run=True again and use the returned plan_hash.",
- source="mutate_graph_properties_tool",
- )
- return _execute_and_verify(
- target=target,
- operation=operation,
- id=id,
- properties=properties,
- before=target_item,
- planned_after=preview["after"],
- cardinalities=cardinalities,
- payload=payload,
- )
+
+def _property_cas_evidence() -> CapabilityEvidence:
+ """Return the locked capability evidence for the supported deployment."""
+
+ return profile_for("1.7.0", "rocksdb").evidence_for(BackendFeature.PROPERTY_COMPARE_AND_SET)
def _validate_inputs(
@@ -282,7 +257,7 @@ def _read_schema_and_target(
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]:
try:
live_schema = current_live_schema()
- except Exception as exc: # noqa: BLE001 - return structured schema error
+ except Exception as exc: # noqa: BLE001 - normalize external schema failure
return (
None,
None,
@@ -298,10 +273,8 @@ def _read_schema_and_target(
try:
manager = _graph_manager()
- raw_item = (
- manager.getVertexById(id) if target == "vertex" else manager.getEdgeById(id)
- )
- except Exception as exc: # noqa: BLE001 - classify client failure
+ raw_item = manager.getVertexById(id) if target == "vertex" else manager.getEdgeById(id)
+ except Exception as exc: # noqa: BLE001 - normalize external graph failure
classification = classify_hugegraph_exception(exc)
return (
live_schema,
@@ -383,14 +356,32 @@ def _validate_properties_against_schema(
details={"label": label, "unknown_properties": unknown},
)
if operation == "append":
+ specs = property_specs(live_schema or {})
for name, value in properties.items():
- cardinality = cardinalities.get(name, "SINGLE")
- if cardinality in {"LIST", "SET"} and not isinstance(value, list):
+ spec = specs.get(name)
+ value_error = property_value_error(
+ item_kind=target,
+ item_index=0,
+ property_name=name,
+ value=value,
+ spec=spec,
+ )
+ if value_error is not None:
+ data_type, cardinality = spec or (
+ None,
+ cardinalities.get(name, "SINGLE"),
+ )
+ suggestion = (
+ f"Pass a JSON array whose elements match {data_type} for {cardinality} property {name!r}."
+ if cardinality in {"LIST", "SET"}
+ else f"Pass a value matching HugeGraph data type {data_type}."
+ )
return _validation_error(
- f"Property {name!r} requires a collection value for append.",
- f"Pass a JSON array for {cardinality} property {name!r}.",
+ value_error,
+ suggestion,
{
"property": name,
+ "data_type": data_type,
"cardinality": cardinality,
"value_type": type(value).__name__,
},
@@ -442,9 +433,7 @@ def _apply_property_preview(
if cardinality == "LIST":
after[name] = [*_existing_collection(after.get(name)), *value]
elif cardinality == "SET":
- after[name] = _stable_unique(
- [*_existing_collection(after.get(name)), *value]
- )
+ after[name] = _stable_unique([*_existing_collection(after.get(name)), *value])
else:
after[name] = value
return after
@@ -453,150 +442,6 @@ def _apply_property_preview(
return after
-def _execute_and_verify(
- *,
- target: str,
- operation: str,
- id: Any,
- properties: dict[str, Any],
- before: dict[str, Any],
- planned_after: dict[str, Any],
- cardinalities: dict[str, str],
- payload: dict[str, Any],
-) -> dict[str, Any]:
- try:
- manager = _graph_manager()
- if target == "vertex":
- raw_result = (
- manager.appendVertex(id, properties)
- if operation == "append"
- else manager.eliminateVertex(id, properties)
- )
- else:
- raw_result = (
- manager.appendEdge(id, properties)
- if operation == "append"
- else manager.eliminateEdge(id, properties)
- )
- except Exception as exc: # noqa: BLE001 - classify client failure
- classification = classify_hugegraph_exception(exc)
- return envelope_err(
- classification.error_type,
- f"Property mutation execution failed: {exc!s}",
- suggestion=classification.suggestion,
- retryable=classification.retryable,
- source="mutate_graph_properties_tool",
- details={
- "stage": "mutation_execute",
- "error": str(exc),
- "target": target,
- "id": id,
- "reason": classification.reason,
- },
- )
-
- raw_result_item = _plain_item(raw_result)
- if raw_result_item is None:
- raw_result_item = planned_after
-
- try:
- manager = _graph_manager()
- post_read = (
- _plain_item(manager.getVertexById(id))
- if target == "vertex"
- else _plain_item(manager.getEdgeById(id))
- )
- except Exception as exc: # noqa: BLE001 - classify client failure
- return _post_write_verification_error(
- message="Mutation returned from HugeGraph, but post-read verification failed.",
- details={
- "stage": "post_write_verification",
- "status": "unknown",
- "target": target,
- "id": id,
- "before": before,
- "planned_after": planned_after,
- "operation_result": raw_result_item,
- "post_read_error": sanitize_for_response(str(exc)),
- },
- warnings=[
- "Mutation returned from HugeGraph, but post-read verification failed."
- ],
- next_actions=["Call query_graph_data_tool to verify the target state."],
- )
-
- if post_read is None:
- return _post_write_verification_error(
- message="Mutation returned from HugeGraph, but the target was not found on post-read.",
- details={
- "stage": "post_write_verification",
- "status": "unknown",
- "target": target,
- "id": id,
- "before": before,
- "planned_after": planned_after,
- "operation_result": raw_result_item,
- "post_read": None,
- },
- warnings=[
- "Mutation returned from HugeGraph, but the target was not found on post-read."
- ],
- next_actions=[
- "Call query_graph_data_tool to verify whether the target still exists."
- ],
- )
-
- if not _properties_match(post_read, planned_after, cardinalities):
- return _post_write_verification_error(
- message="Post-read state did not match the planned preview.",
- details={
- "stage": "post_write_verification",
- "status": "unknown",
- "target": target,
- "id": id,
- "before": before,
- "planned_after": planned_after,
- "operation_result": raw_result_item,
- "post_read": post_read,
- },
- warnings=["Post-read state did not match the planned preview."],
- next_actions=["Call query_graph_data_tool to inspect the target state."],
- )
-
- result_payload = dict(payload)
- result_payload.update(
- {
- "status": "applied",
- "before": before,
- "after": post_read,
- "operation_result": raw_result_item,
- }
- )
- return envelope_ok(
- result_payload,
- warnings=[],
- next_actions=["Call query_graph_data_tool to inspect the updated target."],
- )
-
-
-def _post_write_verification_error(
- *,
- message: str,
- details: dict[str, Any],
- warnings: list[str],
- next_actions: list[str],
-) -> dict[str, Any]:
- return envelope_err(
- ErrorType.PARTIAL_APPLY,
- message,
- suggestion="Inspect the target state before retrying or issuing another write.",
- source="mutate_graph_properties_tool",
- details=details,
- warnings=warnings,
- next_actions=next_actions,
- )
-
-
def _build_mutation_plan_context(
*,
target: str,
@@ -629,16 +474,6 @@ def _nonce_with_snapshot(nonce: str | None, snapshot_digest: str) -> str:
return f"{base}|ts:{snapshot_digest[:16]}"
-def _snapshot_from_nonce(nonce: str | None) -> str | None:
- if nonce is None:
- return None
- marker = "|ts:"
- value = str(nonce)
- if marker not in value:
- return None
- return value.rsplit(marker, 1)[1] or None
-
-
def _payload_digest(
target: str,
operation: str,
@@ -681,25 +516,6 @@ def _find_label_schema(labels: Any, label_name: str) -> dict[str, Any] | None:
return None
-def _properties_match(
- post_read: dict[str, Any],
- planned_after: dict[str, Any],
- cardinalities: dict[str, str],
-) -> bool:
- observed = post_read.get("properties") or {}
- expected = planned_after.get("properties") or {}
- if observed.keys() != expected.keys():
- return False
- for name, expected_value in expected.items():
- observed_value = observed[name]
- if cardinalities.get(name, "SINGLE") == "SET":
- if not _set_values_match(observed_value, expected_value):
- return False
- elif observed_value != expected_value:
- return False
- return True
-
-
def _existing_collection(value: Any) -> list[Any]:
if value is None:
return []
@@ -714,17 +530,6 @@ def _stable_unique(values: list[Any]) -> list[Any]:
return result
-def _set_values_match(observed: Any, expected: Any) -> bool:
- if not isinstance(observed, list) or not isinstance(expected, list):
- return observed == expected
- observed_unique = _stable_unique(observed)
- expected_unique = _stable_unique(expected)
- return len(observed_unique) == len(expected_unique) and all(
- any(observed_value == expected_value for observed_value in observed_unique)
- for expected_value in expected_unique
- )
-
-
def _plain_item(item: Any) -> dict[str, Any] | None:
if item is None:
return None
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/property_validation.py b/hugegraph-mcp/hugegraph_mcp/tools/property_validation.py
new file mode 100644
index 00000000..711c6c0d
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/tools/property_validation.py
@@ -0,0 +1,198 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Shared validation for JSON property values against HugeGraph schema."""
+
+import base64
+import binascii
+import math
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from hugegraph_mcp.tools.schema_utils import normalized_schema_summary
+
+PropertySpec = tuple[str, str]
+
+DATA_TYPE_ALIASES = {
+ "BOOL": "BOOLEAN",
+ "INTEGER": "INT",
+ "STRING": "TEXT",
+}
+SUPPORTED_DATA_TYPES = frozenset(
+ {
+ "TEXT",
+ "UUID",
+ "INT",
+ "LONG",
+ "DOUBLE",
+ "FLOAT",
+ "BOOLEAN",
+ "DATE",
+ "BYTE",
+ "BLOB",
+ "OBJECT",
+ }
+)
+SUPPORTED_CARDINALITIES = frozenset({"SINGLE", "LIST", "SET"})
+
+_INTEGER_RANGES = {
+ "BYTE": (-(2**7), 2**7 - 1),
+ "INT": (-(2**31), 2**31 - 1),
+ "LONG": (-(2**63), 2**63 - 1),
+}
+
+# HugeGraph FLOAT and DOUBLE map to IEEE-754 binary32 and binary64 values.
+# Keep the bounds as exactly representable Python floats, and compare integers
+# directly so arbitrary-precision JSON integers never need a lossy conversion.
+_FLOATING_MAXIMUMS = {
+ "FLOAT": float.fromhex("0x1.fffffep+127"),
+ "DOUBLE": float.fromhex("0x1.fffffffffffffp+1023"),
+}
+
+
+def property_specs(live_schema: dict[str, Any]) -> dict[str, PropertySpec]:
+ """Return normalized ``name -> (data type, cardinality)`` schema entries."""
+
+ specs: dict[str, PropertySpec] = {}
+ normalized_schema = normalized_schema_summary(live_schema) or {}
+ for prop in normalized_schema.get("propertykeys", []):
+ if not isinstance(prop, dict):
+ continue
+ name = prop.get("name")
+ data_type = prop.get("data_type")
+ if not isinstance(name, str) or not isinstance(data_type, str):
+ continue
+ normalized_type = data_type.strip().upper()
+ specs[name] = (
+ DATA_TYPE_ALIASES.get(normalized_type, normalized_type),
+ str(prop.get("cardinality") or "SINGLE").strip().upper(),
+ )
+ return specs
+
+
+def property_value_error(
+ *,
+ item_kind: str,
+ item_index: int,
+ property_name: str,
+ value: Any,
+ spec: PropertySpec | None,
+) -> str | None:
+ """Return a stable validation message, or ``None`` for a valid value.
+
+ ``None`` retains the existing import contract: required/nullable-property
+ policy is owned by label validation and HugeGraph, not guessed here.
+ Collection elements remain non-null because HugeGraph cannot store a null
+ property value inside LIST/SET cardinalities.
+ """
+
+ if spec is None:
+ return None
+
+ data_type, cardinality = spec
+ prefix = f"{item_kind} {item_index} property '{property_name}'"
+ if data_type not in SUPPORTED_DATA_TYPES:
+ return f"{prefix} unsupported data_type '{data_type}'"
+ if cardinality not in SUPPORTED_CARDINALITIES:
+ return f"{prefix} unsupported cardinality '{cardinality}'"
+ if cardinality in {"LIST", "SET"}:
+ if value is None:
+ return None
+ if not isinstance(value, list):
+ return f"{prefix} expects {cardinality} of {data_type}, got {type(value).__name__}"
+ for element_index, element in enumerate(value):
+ if element is None or not value_matches_type(element, data_type):
+ return f"{prefix} element {element_index} expects {data_type}, got {type(element).__name__}"
+ return None
+
+ if not value_matches_type(value, data_type):
+ return f"{prefix} expects {data_type}, got {type(value).__name__}"
+ return None
+
+
+def value_matches_type(value: Any, data_type: str) -> bool:
+ """Check whether a JSON-compatible value can be stored by HugeGraph."""
+
+ if value is None:
+ return True
+ if data_type == "TEXT":
+ return isinstance(value, str)
+ if data_type in _INTEGER_RANGES:
+ if not isinstance(value, int) or isinstance(value, bool):
+ return False
+ minimum, maximum = _INTEGER_RANGES[data_type]
+ return minimum <= value <= maximum
+ if data_type in {"FLOAT", "DOUBLE"}:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return False
+ maximum = _FLOATING_MAXIMUMS[data_type]
+ if isinstance(value, int):
+ return -maximum <= value <= maximum
+ return math.isfinite(value) and -maximum <= value <= maximum
+ if data_type == "BOOLEAN":
+ return isinstance(value, bool)
+ if data_type == "UUID":
+ return _is_uuid(value)
+ if data_type == "DATE":
+ return _is_date(value)
+ if data_type == "BLOB":
+ return _is_blob(value)
+ if data_type == "OBJECT":
+ return isinstance(value, dict)
+ return False
+
+
+def _is_uuid(value: Any) -> bool:
+ if not isinstance(value, str):
+ return False
+ try:
+ UUID(value)
+ except (ValueError, AttributeError):
+ return False
+ return True
+
+
+def _is_date(value: Any) -> bool:
+ if isinstance(value, int) and not isinstance(value, bool):
+ return -(2**63) <= value <= 2**63 - 1
+ if not isinstance(value, str) or not value.strip():
+ return False
+ candidate = value.strip()
+ if candidate.endswith("Z"):
+ candidate = f"{candidate[:-1]}+00:00"
+ try:
+ datetime.fromisoformat(candidate)
+ except ValueError:
+ return False
+ return True
+
+
+def _is_blob(value: Any) -> bool:
+ if isinstance(value, str):
+ if value.startswith("0x"):
+ try:
+ bytes.fromhex(value[2:])
+ except ValueError:
+ return False
+ return True
+ try:
+ base64.b64decode(value, validate=True)
+ except (binascii.Error, ValueError):
+ return False
+ return True
+ if isinstance(value, list):
+ return all(
+ isinstance(element, int) and not isinstance(element, bool) and -128 <= element <= 255 for element in value
+ )
+ return False
diff --git a/hugegraph-mcp/hugegraph_mcp/tools/schema_write_adapter.py b/hugegraph-mcp/hugegraph_mcp/tools/schema_write_adapter.py
new file mode 100644
index 00000000..46b367a4
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/tools/schema_write_adapter.py
@@ -0,0 +1,214 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Canonical plan compiler, executor adapter, and reader for schema creates."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import time
+from collections.abc import Mapping
+from copy import deepcopy
+from typing import Any
+from uuid import uuid4
+
+from hugegraph_mcp.config import MCPConfig
+from hugegraph_mcp.guard import Capability, guard
+from hugegraph_mcp.plan_hash import PlanContext
+from hugegraph_mcp.tools.schema_utils import normalized_schema_summary
+from hugegraph_mcp.write_plan import (
+ ApplyReceipt,
+ ApplyStatus,
+ GraphTarget,
+ OperationPlan,
+ PlanStatus,
+ WritePlan,
+)
+
+CREATE_SCHEMA = "CREATE_SCHEMA"
+
+
+def compile_schema_write_plan(
+ operation: dict[str, Any],
+ *,
+ plan_context: PlanContext,
+ live_schema: dict[str, Any],
+) -> WritePlan:
+ """Compile exactly one validated schema create into an immutable plan."""
+
+ canonical_operation = deepcopy(operation)
+ plan_id = f"wp_{uuid4().hex}"
+ operation_id = f"{plan_id}:op:0000"
+ schema_kind = str(canonical_operation["type"])
+ name = str(canonical_operation["name"])
+ target = {
+ "schema_kind": schema_kind,
+ "name": name,
+ "operation": canonical_operation,
+ }
+ desired = {
+ "exists": True,
+ "schema_kind": schema_kind,
+ "name": name,
+ "operation": canonical_operation,
+ }
+ created_at = int(time.time())
+ return WritePlan(
+ plan_id=plan_id,
+ tool_name="apply_schema_tool",
+ graph_target=GraphTarget(
+ graph_url=plan_context.graph_url,
+ graph_name=plan_context.graph_name,
+ graphspace=plan_context.graphspace,
+ ),
+ principal=plan_context.principal,
+ operations=(
+ OperationPlan(
+ operation_id=operation_id,
+ kind=CREATE_SCHEMA,
+ target=target,
+ expected_state={"exists": False},
+ desired_state=desired,
+ idempotency_key=_digest(target),
+ ),
+ ),
+ payload_digest=_digest({"operations": [canonical_operation]}),
+ schema_fingerprint=_digest(normalized_schema_summary(live_schema)),
+ status=PlanStatus.ISSUED,
+ created_at=created_at,
+ expires_at=max(created_at + 1, int(plan_context.expires_at)),
+ )
+
+
+def schema_create_adapter(plan: WritePlan, operation: OperationPlan, attempt: int) -> ApplyReceipt:
+ """Execute a persisted schema create through the shared safe boundary."""
+
+ mismatch = _target_mismatch(plan, operation, attempt)
+ if mismatch is not None:
+ return mismatch
+ if guard(Capability.SCHEMA_WRITE) is not None:
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.REJECTED,
+ {"write_allowed": False},
+ reason_code="READONLY_VIOLATION",
+ )
+
+ # Delayed import prevents a module cycle: manage_schema imports the plan
+ # compiler, while the default executor registry imports this adapter.
+ from hugegraph_mcp.tools import manage_schema as schema_module
+
+ raw_operation = operation.to_dict()["target"]["operation"]
+ live_schema = schema_module.current_live_schema()
+ result = schema_module.apply_schema_operations(
+ [raw_operation],
+ live_schema=live_schema,
+ )
+ status = ApplyStatus(result["status"])
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ status,
+ result.get("observed_state"),
+ reason_code=_reason_code(status),
+ reconciliation_required=status is ApplyStatus.UNKNOWN,
+ committed=status is ApplyStatus.APPLIED,
+ )
+
+
+def schema_reconcile_reader(_plan: WritePlan, operation: OperationPlan) -> Mapping[str, Any]:
+ """Read one schema object by kind and name without mutating it."""
+
+ from hugegraph_mcp.tools import manage_schema as schema_module
+
+ raw_operation = operation.to_dict()["target"]["operation"]
+ state, observed = schema_module._schema_object_state(
+ raw_operation,
+ schema_module.current_live_schema(),
+ )
+ if state == "identical":
+ return dict(operation.desired_state)
+ if state == "missing":
+ return dict(operation.expected_state)
+ return {
+ "exists": True,
+ "schema_kind": operation.target["schema_kind"],
+ "name": operation.target["name"],
+ "conflicting_object": observed or {},
+ }
+
+
+def register_schema_write_adapters(registry: Any) -> None:
+ registry.register(CREATE_SCHEMA, schema_create_adapter)
+
+
+def register_schema_reconcile_readers(registry: Any) -> None:
+ registry.register(CREATE_SCHEMA, schema_reconcile_reader)
+
+
+def _target_mismatch(
+ plan: WritePlan,
+ operation: OperationPlan,
+ attempt: int,
+) -> ApplyReceipt | None:
+ cfg = MCPConfig.from_env()
+ current = (cfg.url, cfg.graph, cfg.graphspace or "DEFAULT", cfg.user)
+ planned = (
+ plan.graph_target.graph_url,
+ plan.graph_target.graph_name,
+ plan.graph_target.graphspace or "DEFAULT",
+ plan.principal,
+ )
+ if current == planned:
+ return None
+ return _receipt(
+ plan,
+ operation,
+ attempt,
+ ApplyStatus.REJECTED,
+ {"target_matches": False},
+ reason_code="TARGET_CHANGED",
+ )
+
+
+def _receipt(
+ plan: WritePlan,
+ operation: OperationPlan,
+ attempt: int,
+ status: ApplyStatus,
+ observed_state: Mapping[str, Any] | None,
+ *,
+ reason_code: str,
+ reconciliation_required: bool = False,
+ committed: bool = False,
+) -> ApplyReceipt:
+ return ApplyReceipt(
+ plan_id=plan.plan_id,
+ operation_id=operation.operation_id,
+ status=status,
+ observed_state=observed_state,
+ reason_code=reason_code,
+ attempt=attempt,
+ reconciliation_required=reconciliation_required,
+ committed_at=int(time.time()) if committed else None,
+ )
+
+
+def _reason_code(status: ApplyStatus) -> str:
+ return {
+ ApplyStatus.APPLIED: "SCHEMA_CREATED",
+ ApplyStatus.ALREADY_APPLIED: "SCHEMA_ALREADY_APPLIED",
+ ApplyStatus.CONFLICT: "SCHEMA_OBJECT_CONFLICT",
+ ApplyStatus.UNKNOWN: "SCHEMA_CREATE_OUTCOME_UNKNOWN",
+ }.get(status, status.value)
+
+
+def _digest(value: Any) -> str:
+ encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
diff --git a/hugegraph-mcp/hugegraph_mcp/write_contract.py b/hugegraph-mcp/hugegraph_mcp/write_contract.py
new file mode 100644
index 00000000..d648acc3
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/write_contract.py
@@ -0,0 +1,109 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Compatibility rules for canonical and legacy confirmation locators."""
+
+from dataclasses import dataclass
+from enum import Enum
+
+from hugegraph_mcp.envelope import ErrorType, envelope_err
+
+LEGACY_DEPRECATION_CODE = "LEGACY_CONFIRMATION_DEPRECATED"
+
+
+class ConfirmationProtocol(str, Enum):
+ CANONICAL = "canonical"
+ LEGACY = "legacy"
+
+
+@dataclass(frozen=True)
+class ConfirmationResolution:
+ protocol: ConfirmationProtocol
+ plan_id: str | None
+ plan_hash: str | None
+ nonce: str | None
+ expires_at: float | None
+ warnings: tuple[str, ...] = ()
+
+
+def resolve_confirmation_protocol(
+ *,
+ plan_id: str | None = None,
+ plan_hash: str | None = None,
+ nonce: str | None = None,
+ expires_at: float | None = None,
+) -> tuple[ConfirmationResolution | None, dict | None]:
+ """Resolve one mutually exclusive confirmation protocol fail-closed."""
+
+ canonical_present = bool(plan_id)
+ legacy_values = (plan_hash, nonce, expires_at)
+ legacy_present = any(value is not None for value in legacy_values)
+ legacy_complete = all(value is not None for value in legacy_values)
+
+ if canonical_present and legacy_present:
+ return None, _protocol_error("plan_id cannot be combined with plan_hash, nonce, or expires_at.")
+ if canonical_present:
+ return (
+ ConfirmationResolution(
+ protocol=ConfirmationProtocol.CANONICAL,
+ plan_id=plan_id,
+ plan_hash=None,
+ nonce=None,
+ expires_at=None,
+ ),
+ None,
+ )
+ if legacy_present and not legacy_complete:
+ return None, _protocol_error("plan_hash, nonce, and expires_at must be supplied together.")
+ if legacy_complete:
+ return (
+ ConfirmationResolution(
+ protocol=ConfirmationProtocol.LEGACY,
+ plan_id=None,
+ plan_hash=plan_hash,
+ nonce=nonce,
+ expires_at=expires_at,
+ warnings=(
+ (
+ f"{LEGACY_DEPRECATION_CODE}: use plan_id with confirm_write_tool; "
+ "legacy confirmation fields will be removed after one release."
+ ),
+ ),
+ ),
+ None,
+ )
+ return None, _protocol_error("A plan_id or complete legacy locator is required.")
+
+
+def resolve_optional_legacy_locator(
+ *,
+ plan_hash: str | None = None,
+ nonce: str | None = None,
+ expires_at: float | None = None,
+) -> tuple[ConfirmationResolution | None, dict | None]:
+ """Validate an optional legacy locator before a public write wrapper runs.
+
+ Dry-run and validation calls legitimately omit confirmation fields. Once
+ any legacy field is present, however, the complete deprecated locator is
+ required. The returned resolution is metadata only: execution must still
+ load the server-persisted plan rather than trusting the request payload.
+ """
+
+ if plan_hash is None and nonce is None and expires_at is None:
+ return None, None
+ return resolve_confirmation_protocol(
+ plan_hash=plan_hash,
+ nonce=nonce,
+ expires_at=expires_at,
+ )
+
+
+def _protocol_error(reason: str) -> dict:
+ return envelope_err(
+ ErrorType.VALIDATION_ERROR,
+ "Invalid write confirmation protocol.",
+ retryable=False,
+ details={"reason": reason},
+ )
diff --git a/hugegraph-mcp/hugegraph_mcp/write_executor.py b/hugegraph-mcp/hugegraph_mcp/write_executor.py
new file mode 100644
index 00000000..38105b03
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/write_executor.py
@@ -0,0 +1,317 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Unified plan-ID confirmation and executor dispatch."""
+
+from __future__ import annotations
+
+import time
+import uuid
+from collections.abc import Callable
+from dataclasses import replace
+from math import ceil
+
+from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok
+from hugegraph_mcp.plan_store import (
+ PlanStore,
+ PlanTransitionError,
+ plan_store_from_config,
+)
+from hugegraph_mcp.write_plan import (
+ ApplyReceipt,
+ ApplyStatus,
+ OperationPlan,
+ PlanStatus,
+ WritePlan,
+)
+
+WriteAdapter = Callable[[WritePlan, OperationPlan, int], ApplyReceipt]
+LEASE_SAFETY_MARGIN_SECONDS = 5
+
+
+class WriteExecutorRegistry:
+ def __init__(self) -> None:
+ self._adapters: dict[str, WriteAdapter] = {}
+
+ def register(self, kind: str, adapter: WriteAdapter) -> None:
+ if not kind:
+ raise ValueError("adapter kind must be non-empty")
+ self._adapters[kind] = adapter
+
+ def adapter_for(self, kind: str) -> WriteAdapter | None:
+ return self._adapters.get(kind)
+
+
+class WriteExecutor:
+ def __init__(
+ self,
+ *,
+ store: PlanStore,
+ registry: WriteExecutorRegistry,
+ lease_seconds: int = 300,
+ ):
+ self.store = store
+ self.registry = registry
+ self.lease_seconds = lease_seconds
+
+ def confirm(self, plan_id: str) -> dict:
+ plan = self.store.get_plan(plan_id)
+ if plan is None:
+ return envelope_err(
+ ErrorType.NOT_FOUND,
+ "No server-issued write plan matches this plan_id.",
+ retryable=False,
+ )
+ if int(time.time()) > plan.expires_at and plan.status is PlanStatus.ISSUED:
+ self.store.transition_plan(
+ plan_id,
+ expected=PlanStatus.ISSUED,
+ target=PlanStatus.EXPIRED,
+ )
+ return self._confirmation_result(plan_id)
+
+ missing = [operation.kind for operation in plan.operations if self.registry.adapter_for(operation.kind) is None]
+ if missing:
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "The confirmed plan requires an unavailable atomic write adapter.",
+ retryable=False,
+ details={"missing_operation_kinds": sorted(set(missing))},
+ )
+
+ try:
+ owner_token = uuid.uuid4().hex
+ claimed_plan, claimed = self.store.claim_plan(
+ plan_id,
+ owner_token=owner_token,
+ lease_seconds=self.lease_seconds,
+ )
+ except PlanTransitionError:
+ return self._confirmation_result(plan_id)
+ if not claimed:
+ return self._confirmation_result(plan_id)
+
+ return self._execute_claimed(
+ claimed_plan,
+ claimable={PlanStatus.ISSUED.value},
+ owner_token=owner_token,
+ )
+
+ def resume(self, plan_id: str) -> dict:
+ plan = self.store.get_plan(plan_id)
+ record = self.store.get_plan_record(plan_id)
+ if plan is None or record is None:
+ raise PlanTransitionError("resume requires a reconciled RETRYABLE_NOT_APPLIED plan")
+ operation_statuses = {operation["operation_id"]: operation["status"] for operation in record["operations"]}
+ reconciled_operations = {
+ operation["operation_id"]: operation["reconciled"] for operation in record["operations"]
+ }
+ statuses = set(operation_statuses.values())
+ resumable = {
+ PlanStatus.APPLIED.value,
+ PlanStatus.ALREADY_APPLIED.value,
+ PlanStatus.RETRYABLE_NOT_APPLIED.value,
+ }
+ if (
+ plan.status not in {PlanStatus.RETRYABLE_NOT_APPLIED, PlanStatus.PARTIAL}
+ or PlanStatus.RETRYABLE_NOT_APPLIED.value not in statuses
+ or not statuses <= resumable
+ or any(
+ not reconciled_operations[operation_id]
+ for operation_id, status in operation_statuses.items()
+ if status == PlanStatus.RETRYABLE_NOT_APPLIED.value
+ )
+ ):
+ raise PlanTransitionError(
+ "resume requires reconciled operations whose remaining state is RETRYABLE_NOT_APPLIED"
+ )
+ missing = [
+ operation.kind
+ for operation in plan.operations
+ if operation_statuses[operation.operation_id] == PlanStatus.RETRYABLE_NOT_APPLIED.value
+ and self.registry.adapter_for(operation.kind) is None
+ ]
+ if missing:
+ return envelope_err(
+ ErrorType.FEATURE_DISABLED,
+ "The reconciled plan requires an unavailable atomic write adapter.",
+ retryable=False,
+ details={"missing_operation_kinds": sorted(set(missing))},
+ )
+ owner_token = uuid.uuid4().hex
+ claimed_plan, claimed = self.store.claim_plan(
+ plan_id,
+ owner_token=owner_token,
+ lease_seconds=self.lease_seconds,
+ resume=True,
+ )
+ if not claimed:
+ raise PlanTransitionError("resumable plan could not be exclusively leased")
+ return self._execute_claimed(
+ claimed_plan,
+ claimable={PlanStatus.RETRYABLE_NOT_APPLIED.value},
+ owner_token=owner_token,
+ )
+
+ def _execute_claimed(
+ self,
+ claimed_plan: WritePlan,
+ *,
+ claimable: set[str],
+ owner_token: str,
+ ) -> dict:
+ plan_id = claimed_plan.plan_id
+ record = self.store.get_plan_record(plan_id)
+ if record is None:
+ raise PlanTransitionError("claimed plan disappeared before execution")
+ operation_statuses = {operation["operation_id"]: operation["status"] for operation in record["operations"]}
+
+ for operation in claimed_plan.operations:
+ if operation_statuses[operation.operation_id] not in claimable:
+ continue
+ adapter = self.registry.adapter_for(operation.kind)
+ assert adapter is not None
+ attempt_token = uuid.uuid4().hex
+ attempt = self.store.claim_operation(
+ plan_id,
+ operation.operation_id,
+ owner_token=owner_token,
+ attempt_token=attempt_token,
+ lease_seconds=self.lease_seconds,
+ )
+ try:
+ receipt = adapter(claimed_plan, operation, attempt)
+ except Exception as exc: # noqa: BLE001 - persist ambiguous adapter outcome
+ receipt = ApplyReceipt(
+ plan_id=plan_id,
+ operation_id=operation.operation_id,
+ status=ApplyStatus.UNKNOWN,
+ reason_code=type(exc).__name__,
+ attempt=attempt,
+ reconciliation_required=True,
+ )
+ receipt = replace(receipt, attempt_token=attempt_token)
+ self.store.record_receipt(
+ receipt,
+ owner_token=owner_token,
+ attempt_token=attempt_token,
+ )
+ if receipt.status not in {
+ ApplyStatus.APPLIED,
+ ApplyStatus.ALREADY_APPLIED,
+ }:
+ break
+ return self._confirmation_result(plan_id)
+
+ def _confirmation_result(self, plan_id: str) -> dict:
+ status_result = self.status(plan_id)
+ if not status_result.get("ok"):
+ return status_result
+ data = status_result["data"]
+ status = PlanStatus(data["status"])
+ if status in {PlanStatus.APPLIED, PlanStatus.ALREADY_APPLIED}:
+ return status_result
+ error_type = {
+ PlanStatus.PARTIAL: ErrorType.PARTIAL_APPLY,
+ PlanStatus.CONFLICT: ErrorType.WRITE_CONFLICT,
+ PlanStatus.REJECTED: ErrorType.FLOW_EXECUTION_FAILED,
+ PlanStatus.EXPIRED: ErrorType.PLAN_EXPIRED,
+ }.get(status, ErrorType.WRITE_OUTCOME_UNKNOWN)
+ return envelope_err(
+ error_type,
+ "The confirmed write plan did not reach an applied final state.",
+ retryable=False,
+ details=data,
+ )
+
+ def status(self, plan_id: str) -> dict:
+ record = self.store.get_plan_record(plan_id)
+ if record is None:
+ return envelope_err(
+ ErrorType.NOT_FOUND,
+ "No persisted write plan matches this plan_id.",
+ retryable=False,
+ )
+ public = dict(record)
+ public.pop("payload", None)
+ public["operations"] = [
+ {
+ key: (
+ {
+ receipt_key: receipt_value
+ for receipt_key, receipt_value in value.items()
+ if receipt_key != "attempt_token"
+ }
+ if key == "receipt" and isinstance(value, dict)
+ else value
+ )
+ for key, value in operation.items()
+ if key not in {"payload", "attempt_token", "reconciled"}
+ }
+ for operation in record["operations"]
+ ]
+ if public["status"] == PlanStatus.LEGACY_UNKNOWN.value:
+ public["status"] = PlanStatus.UNKNOWN.value
+ public["reconciliation_required"] = True
+ elif public["status"] == PlanStatus.EXECUTING.value:
+ lease_expires_at = public.get("lease_expires_at")
+ if lease_expires_at is None or int(lease_expires_at) <= int(time.time()):
+ public["status"] = PlanStatus.UNKNOWN.value
+ public["reconciliation_required"] = True
+ else:
+ public["reconciliation_required"] = False
+ public.pop("lease_owner", None)
+ return envelope_ok(public)
+
+
+DEFAULT_WRITE_EXECUTOR_REGISTRY = WriteExecutorRegistry()
+
+# Graph adapters are registered here, after the registry type is defined, to
+# keep the adapter module independent of executor orchestration.
+from hugegraph_mcp.tools.graph_write_adapter import (
+ register_graph_write_adapters,
+)
+from hugegraph_mcp.tools.schema_write_adapter import (
+ register_schema_write_adapters,
+)
+
+register_graph_write_adapters(DEFAULT_WRITE_EXECUTOR_REGISTRY)
+register_schema_write_adapters(DEFAULT_WRITE_EXECUTOR_REGISTRY)
+
+
+def confirm_write(plan_id: str) -> dict:
+ try:
+ from hugegraph_mcp.config import MCPConfig
+
+ cfg = MCPConfig.from_env()
+ store = plan_store_from_config(cfg)
+ except Exception as exc: # noqa: BLE001 - public tool must remain enveloped
+ return envelope_err(
+ ErrorType.SERVER_ERROR,
+ "The durable plan store is unavailable.",
+ retryable=False,
+ details={"reason": type(exc).__name__},
+ )
+ return WriteExecutor(
+ store=store,
+ registry=DEFAULT_WRITE_EXECUTOR_REGISTRY,
+ lease_seconds=ceil(cfg.write_timeout_seconds) + LEASE_SAFETY_MARGIN_SECONDS,
+ ).confirm(plan_id)
+
+
+def get_write_status(plan_id: str) -> dict:
+ try:
+ store = plan_store_from_config()
+ except Exception as exc: # noqa: BLE001 - public tool must remain enveloped
+ return envelope_err(
+ ErrorType.SERVER_ERROR,
+ "The durable plan store is unavailable.",
+ retryable=False,
+ details={"reason": type(exc).__name__},
+ )
+ return WriteExecutor(
+ store=store,
+ registry=DEFAULT_WRITE_EXECUTOR_REGISTRY,
+ ).status(plan_id)
diff --git a/hugegraph-mcp/hugegraph_mcp/write_plan.py b/hugegraph-mcp/hugegraph_mcp/write_plan.py
new file mode 100644
index 00000000..03c87b14
--- /dev/null
+++ b/hugegraph-mcp/hugegraph_mcp/write_plan.py
@@ -0,0 +1,321 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+"""Canonical immutable models and state rules for confirmed writes."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from enum import Enum
+from types import MappingProxyType
+from typing import Any
+
+
+class ApplyStatus(str, Enum):
+ """Evidence-backed outcome of one write operation."""
+
+ APPLIED = "APPLIED"
+ ALREADY_APPLIED = "ALREADY_APPLIED"
+ REJECTED = "REJECTED"
+ CONFLICT = "CONFLICT"
+ PARTIAL = "PARTIAL"
+ UNKNOWN = "UNKNOWN"
+ LEGACY_UNKNOWN = "LEGACY_UNKNOWN"
+ RETRYABLE_NOT_APPLIED = "RETRYABLE_NOT_APPLIED"
+
+
+class PlanStatus(str, Enum):
+ """Lifecycle and aggregate outcome of one immutable write plan."""
+
+ ISSUED = "ISSUED"
+ EXECUTING = "EXECUTING"
+ APPLIED = "APPLIED"
+ ALREADY_APPLIED = "ALREADY_APPLIED"
+ REJECTED = "REJECTED"
+ CONFLICT = "CONFLICT"
+ PARTIAL = "PARTIAL"
+ UNKNOWN = "UNKNOWN"
+ LEGACY_UNKNOWN = "LEGACY_UNKNOWN"
+ RETRYABLE_NOT_APPLIED = "RETRYABLE_NOT_APPLIED"
+ EXPIRED = "EXPIRED"
+
+
+ALLOWED_PLAN_TRANSITIONS: Mapping[PlanStatus, frozenset[PlanStatus]] = MappingProxyType(
+ {
+ PlanStatus.ISSUED: frozenset({PlanStatus.EXECUTING, PlanStatus.EXPIRED}),
+ PlanStatus.EXECUTING: frozenset(
+ {
+ PlanStatus.APPLIED,
+ PlanStatus.ALREADY_APPLIED,
+ PlanStatus.REJECTED,
+ PlanStatus.CONFLICT,
+ PlanStatus.PARTIAL,
+ PlanStatus.UNKNOWN,
+ }
+ ),
+ PlanStatus.UNKNOWN: frozenset(
+ {
+ PlanStatus.APPLIED,
+ PlanStatus.CONFLICT,
+ PlanStatus.RETRYABLE_NOT_APPLIED,
+ }
+ ),
+ PlanStatus.LEGACY_UNKNOWN: frozenset(
+ {
+ PlanStatus.APPLIED,
+ PlanStatus.CONFLICT,
+ PlanStatus.RETRYABLE_NOT_APPLIED,
+ }
+ ),
+ PlanStatus.RETRYABLE_NOT_APPLIED: frozenset({PlanStatus.EXECUTING}),
+ PlanStatus.PARTIAL: frozenset({PlanStatus.PARTIAL, PlanStatus.APPLIED, PlanStatus.UNKNOWN}),
+ PlanStatus.APPLIED: frozenset(),
+ PlanStatus.ALREADY_APPLIED: frozenset(),
+ PlanStatus.REJECTED: frozenset(),
+ PlanStatus.CONFLICT: frozenset(),
+ PlanStatus.EXPIRED: frozenset(),
+ }
+)
+
+
+@dataclass(frozen=True)
+class GraphTarget:
+ graph_url: str
+ graph_name: str
+ graphspace: str | None
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "graph_url": self.graph_url,
+ "graph_name": self.graph_name,
+ "graphspace": self.graphspace,
+ }
+
+
+@dataclass(frozen=True)
+class OperationPlan:
+ operation_id: str
+ kind: str
+ target: Mapping[str, Any]
+ expected_state: Mapping[str, Any]
+ desired_state: Mapping[str, Any]
+ depends_on: tuple[str, ...] = ()
+ idempotency_key: str | None = None
+
+ def __post_init__(self) -> None:
+ if not self.operation_id:
+ raise ValueError("operation_id must be non-empty")
+ if not self.kind:
+ raise ValueError("operation kind must be non-empty")
+ object.__setattr__(self, "target", _freeze_mapping(self.target))
+ object.__setattr__(
+ self,
+ "expected_state",
+ _freeze_mapping(self.expected_state),
+ )
+ object.__setattr__(
+ self,
+ "desired_state",
+ _freeze_mapping(self.desired_state),
+ )
+ object.__setattr__(self, "depends_on", tuple(self.depends_on))
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "operation_id": self.operation_id,
+ "kind": self.kind,
+ "target": _thaw(self.target),
+ "expected_state": _thaw(self.expected_state),
+ "desired_state": _thaw(self.desired_state),
+ "depends_on": list(self.depends_on),
+ "idempotency_key": self.idempotency_key,
+ }
+
+
+@dataclass(frozen=True)
+class WritePlan:
+ plan_id: str
+ tool_name: str
+ graph_target: GraphTarget
+ principal: str
+ operations: tuple[OperationPlan, ...]
+ payload_digest: str
+ schema_fingerprint: str | None
+ status: PlanStatus
+ created_at: int
+ expires_at: int
+
+ def __post_init__(self) -> None:
+ if not self.plan_id:
+ raise ValueError("plan_id must be non-empty")
+ if not self.tool_name:
+ raise ValueError("tool_name must be non-empty")
+ operations = tuple(self.operations)
+ object.__setattr__(self, "operations", operations)
+
+ seen: set[str] = set()
+ for operation in operations:
+ if operation.operation_id in seen:
+ raise ValueError(f"duplicate operation_id: {operation.operation_id}")
+ missing = [dependency for dependency in operation.depends_on if dependency not in seen]
+ if missing:
+ raise ValueError(
+ f"operation {operation.operation_id} depends on an earlier operation: {', '.join(missing)}"
+ )
+ seen.add(operation.operation_id)
+
+ if self.created_at < 0 or self.expires_at <= self.created_at:
+ raise ValueError("expires_at must be greater than created_at")
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "plan_id": self.plan_id,
+ "tool_name": self.tool_name,
+ "graph_target": self.graph_target.to_dict(),
+ "principal": self.principal,
+ "operations": [operation.to_dict() for operation in self.operations],
+ "payload_digest": self.payload_digest,
+ "schema_fingerprint": self.schema_fingerprint,
+ "status": self.status.value,
+ "created_at": self.created_at,
+ "expires_at": self.expires_at,
+ }
+
+
+@dataclass(frozen=True)
+class ApplyReceipt:
+ """Canonical receipt with a temporary legacy construction shape."""
+
+ status: ApplyStatus
+ plan_id: str | None = None
+ operation_id: str | None = None
+ observed_state: Mapping[str, Any] | None = None
+ reason_code: str | None = None
+ attempt: int = 0
+ attempt_token: str | None = None
+ reconciliation_required: bool = False
+ committed_at: int | None = None
+ operation: Mapping[str, Any] | None = None
+ reason: str | None = None
+
+ def __post_init__(self) -> None:
+ if self.attempt < 0:
+ raise ValueError("attempt must not be negative")
+ if self.observed_state is not None:
+ object.__setattr__(
+ self,
+ "observed_state",
+ _freeze_mapping(self.observed_state),
+ )
+ if self.operation is not None:
+ object.__setattr__(
+ self,
+ "operation",
+ _freeze_mapping(self.operation),
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ if self.plan_id is None and self.operation_id is None:
+ return {
+ "status": self.status.value,
+ "operation": _thaw(self.operation),
+ "observed_state": _thaw(self.observed_state),
+ "reason": self.reason,
+ "reconciliation_required": self.reconciliation_required,
+ }
+ return {
+ "plan_id": self.plan_id,
+ "operation_id": self.operation_id,
+ "status": self.status.value,
+ "observed_state": _thaw(self.observed_state),
+ "reason_code": self.reason_code,
+ "attempt": self.attempt,
+ "reconciliation_required": self.reconciliation_required,
+ "committed_at": self.committed_at,
+ **({"attempt_token": self.attempt_token} if self.attempt_token is not None else {}),
+ }
+
+
+def can_transition(current: PlanStatus, target: PlanStatus) -> bool:
+ """Return whether the explicit state machine permits this transition."""
+
+ return target in ALLOWED_PLAN_TRANSITIONS.get(current, frozenset())
+
+
+def aggregate_plan_status(
+ operation_statuses: Sequence[ApplyStatus],
+) -> PlanStatus:
+ """Aggregate operation evidence without hiding known partial writes."""
+
+ statuses = tuple(operation_statuses)
+ if not statuses:
+ return PlanStatus.ISSUED
+
+ success = {ApplyStatus.APPLIED, ApplyStatus.ALREADY_APPLIED}
+ if all(status in success for status in statuses):
+ if all(status is ApplyStatus.ALREADY_APPLIED for status in statuses):
+ return PlanStatus.ALREADY_APPLIED
+ return PlanStatus.APPLIED
+ if any(status in success for status in statuses):
+ return PlanStatus.PARTIAL
+ if ApplyStatus.PARTIAL in statuses:
+ return PlanStatus.PARTIAL
+ if ApplyStatus.UNKNOWN in statuses:
+ return PlanStatus.UNKNOWN
+ if ApplyStatus.CONFLICT in statuses:
+ return PlanStatus.CONFLICT
+ if ApplyStatus.RETRYABLE_NOT_APPLIED in statuses:
+ return PlanStatus.RETRYABLE_NOT_APPLIED
+ return PlanStatus.REJECTED
+
+
+def canonical_plan_json(plan: WritePlan) -> str:
+ """Serialize one plan deterministically for integrity checks."""
+
+ return json.dumps(
+ plan.to_dict(),
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+
+
+def canonical_plan_digest(plan: WritePlan) -> str:
+ """Return the full SHA-256 digest of a canonical plan."""
+
+ return hashlib.sha256(canonical_plan_json(plan).encode("utf-8")).hexdigest()
+
+
+def _freeze_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]:
+ if not isinstance(value, Mapping):
+ raise TypeError("write plan state must be a mapping")
+ return MappingProxyType({str(key): _freeze(item) for key, item in value.items()})
+
+
+def _freeze(value: Any) -> Any:
+ if isinstance(value, Mapping):
+ return _freeze_mapping(value)
+ if isinstance(value, (list, tuple)):
+ return tuple(_freeze(item) for item in value)
+ if isinstance(value, (set, frozenset)):
+ return frozenset(_freeze(item) for item in value)
+ return value
+
+
+def _thaw(value: Any) -> Any:
+ if value is None:
+ return None
+ if isinstance(value, Mapping):
+ return {str(key): _thaw(item) for key, item in value.items()}
+ if isinstance(value, tuple):
+ return [_thaw(item) for item in value]
+ if isinstance(value, frozenset):
+ return sorted((_thaw(item) for item in value), key=repr)
+ if isinstance(value, Enum):
+ return value.value
+ return value
diff --git a/hugegraph-mcp/tests/contracts/write_confirmation_v2.json b/hugegraph-mcp/tests/contracts/write_confirmation_v2.json
new file mode 100644
index 00000000..185e0bdb
--- /dev/null
+++ b/hugegraph-mcp/tests/contracts/write_confirmation_v2.json
@@ -0,0 +1,42 @@
+{
+ "contract_version": "2.1",
+ "canonical": {
+ "authoritative_source": "server_persisted_plan",
+ "confirm_tool": "confirm_write_tool",
+ "confirm_request_fields": [
+ "plan_id"
+ ],
+ "status_tool": "get_write_status_tool",
+ "reconcile_tool": "reconcile_write_tool",
+ "repeated_confirmation": "return_persisted_status",
+ "mixed_protocol": "reject",
+ "client_payload_authoritative": false
+ },
+ "legacy": {
+ "accepted_for_releases": 1,
+ "locator_fields": [
+ "plan_hash",
+ "nonce",
+ "expires_at"
+ ],
+ "fields_are_all_or_nothing": true,
+ "authoritative_source": "server_persisted_plan",
+ "client_payload_authoritative": false,
+ "response_warning_code": "LEGACY_CONFIRMATION_DEPRECATED"
+ },
+ "outcomes": [
+ "APPLIED",
+ "ALREADY_APPLIED",
+ "REJECTED",
+ "CONFLICT",
+ "PARTIAL",
+ "UNKNOWN"
+ ],
+ "direct_retryable_outcomes": [],
+ "errors": {
+ "ambiguous": "WRITE_OUTCOME_UNKNOWN",
+ "conflict": "WRITE_CONFLICT",
+ "partial": "PARTIAL_APPLY",
+ "unsupported_atomic_primitive": "FEATURE_DISABLED"
+ }
+}
diff --git a/hugegraph-mcp/tests/integration/test_real_write_path.py b/hugegraph-mcp/tests/integration/test_real_write_path.py
index 1a2e5727..86218a3f 100644
--- a/hugegraph-mcp/tests/integration/test_real_write_path.py
+++ b/hugegraph-mcp/tests/integration/test_real_write_path.py
@@ -16,16 +16,17 @@
from __future__ import annotations
import time
+from concurrent.futures import ThreadPoolExecutor
+from threading import Barrier
from uuid import uuid4
import pytest
-from pyhugegraph.client import PyHugeClient
-
from hugegraph_mcp import server
from hugegraph_mcp.config import MCPConfig
from hugegraph_mcp.hugegraph_client import build_hugegraph_client
from hugegraph_mcp.tools import manage_graph_data as manage_graph_data_module
from hugegraph_mcp.tools.graph_data_gremlin import _g
+from pyhugegraph.client import PyHugeClient
pytestmark = [pytest.mark.integration, pytest.mark.real_hugegraph]
@@ -33,14 +34,10 @@ pytestmark = [pytest.mark.integration, pytest.mark.real_hugegraph]
@pytest.fixture
def hugegraph_client(monkeypatch):
if _env("RUN_MCP_REAL_HUGEGRAPH_TESTS") != "1":
- pytest.skip(
- "set RUN_MCP_REAL_HUGEGRAPH_TESTS=1 to run real HugeGraph write tests"
- )
+ pytest.skip("set RUN_MCP_REAL_HUGEGRAPH_TESTS=1 to run real HugeGraph write tests")
monkeypatch.setenv("HUGEGRAPH_URL", _env("HUGEGRAPH_URL", "http://127.0.0.1:8080"))
- monkeypatch.setenv(
- "HUGEGRAPH_GRAPH_PATH", _env("HUGEGRAPH_GRAPH_PATH", "DEFAULT/hugegraph")
- )
+ monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", _env("HUGEGRAPH_GRAPH_PATH", "DEFAULT/hugegraph"))
monkeypatch.setenv("HUGEGRAPH_USER", _env("HUGEGRAPH_USER", "admin"))
monkeypatch.setenv("HUGEGRAPH_PASSWORD", _env("HUGEGRAPH_PASSWORD", "admin"))
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
@@ -62,24 +59,27 @@ def hugegraph_client(monkeypatch):
return client
-def test_id_based_ingest_writes_the_intended_edge(hugegraph_client):
+def test_id_based_ingest_is_preview_only_without_create_capability(
+ hugegraph_client,
+):
names = _schema_names("id_edge")
_ensure_custom_id_schema(hugegraph_client, names)
_exec(
hugegraph_client,
- f"g.addV({_g(names.vertex_label)}).property(T.id,'decoy')"
- f".property({_g(names.name_key)},'Alice')",
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'decoy').property({_g(names.name_key)},'Alice')",
)
+ alice_id = f"alice_{uuid4().hex}"
+ bob_id = f"bob_{uuid4().hex}"
graph_data = {
"vertices": [
{
- "id": "alice",
+ "id": alice_id,
"label": names.vertex_label,
"properties": {names.name_key: "Alice"},
},
{
- "id": "bob",
+ "id": bob_id,
"label": names.vertex_label,
"properties": {names.name_key: "Bob"},
},
@@ -89,21 +89,29 @@ def test_id_based_ingest_writes_the_intended_edge(hugegraph_client):
"label": names.edge_label,
"source_label": names.vertex_label,
"target_label": names.vertex_label,
- "source": {"id": "alice"},
- "target": {"id": "bob"},
+ "source": {"id": alice_id},
+ "target": {"id": bob_id},
}
],
}
result = _import_graph_data(graph_data)
- assert result["ok"] is True
+ assert result["ok"] is False
+ assert result["error"]["type"] == "FEATURE_DISABLED"
assert (
_count(
hugegraph_client,
- f"g.V().hasId('alice').out({_g(names.edge_label)}).hasId('bob')",
+ f"g.V().hasId({_g(alice_id)},{_g(bob_id)})",
)
- == 1
+ == 0
+ )
+ assert (
+ _count(
+ hugegraph_client,
+ f"g.V().hasId({_g(alice_id)}).out({_g(names.edge_label)}).hasId({_g(bob_id)})",
+ )
+ == 0
)
assert (
_count(
@@ -144,46 +152,25 @@ def test_apply_schema_forwards_and_verifies_supported_fields(hugegraph_client):
},
]
- dry_run = server.apply_schema_tool(mode="dry_run", operations=operations)
- assert dry_run["ok"] is True
- assert dry_run["data"]["valid"] is True
- context = dry_run["data"]["plan_context"]
-
- applied = server.apply_schema_tool(
- mode="apply",
- operations=operations,
- confirm=True,
- plan_hash=dry_run["data"]["plan_hash"],
- nonce=context["nonce"],
- expires_at=context["expires_at"],
- )
- assert applied["ok"] is True
- assert applied["data"]["status"] == "applied"
+ for operation in operations:
+ applied = _apply_schema_operation(operation)
+ assert applied["data"]["status"] == "APPLIED"
+ assert applied["data"]["operations"][0]["status"] == "APPLIED"
+ receipt = applied["data"]["operations"][0]["receipt"]
+ assert receipt["status"] == "APPLIED"
+ assert receipt["reason_code"] == "SCHEMA_CREATED"
+ assert receipt["committed_at"] is not None
schema = hugegraph_client.schema().getSchema()
raw_schema = schema.get("schema", schema)
- property_key = next(
- item for item in raw_schema["propertykeys"] if item.get("name") == property_name
- )
- vertex_label = next(
- item for item in raw_schema["vertexlabels"] if item.get("name") == label_name
- )
- edge_label = next(
- item
- for item in raw_schema["edgelabels"]
- if item.get("name") == f"mcp_schema_edge_{suffix}"
- )
- assert _user_data_without_server_metadata(property_key.get("user_data")) == {
- "owner": "mcp-integration"
- }
+ property_key = next(item for item in raw_schema["propertykeys"] if item.get("name") == property_name)
+ vertex_label = next(item for item in raw_schema["vertexlabels"] if item.get("name") == label_name)
+ edge_label = next(item for item in raw_schema["edgelabels"] if item.get("name") == f"mcp_schema_edge_{suffix}")
+ assert _user_data_without_server_metadata(property_key.get("user_data")) == {"owner": "mcp-integration"}
assert vertex_label.get("enable_label_index") is False
- assert _user_data_without_server_metadata(vertex_label.get("user_data")) == {
- "owner": "mcp-integration"
- }
+ assert _user_data_without_server_metadata(vertex_label.get("user_data")) == {"owner": "mcp-integration"}
assert edge_label.get("enable_label_index") is True
- assert _user_data_without_server_metadata(edge_label.get("user_data")) == {
- "owner": "mcp-integration-edge"
- }
+ assert _user_data_without_server_metadata(edge_label.get("user_data")) == {"owner": "mcp-integration-edge"}
def test_create_edge_rejects_missing_endpoint(hugegraph_client):
@@ -191,8 +178,7 @@ def test_create_edge_rejects_missing_endpoint(hugegraph_client):
_ensure_custom_id_schema(hugegraph_client, names)
_exec(
hugegraph_client,
- f"g.addV({_g(names.vertex_label)}).property(T.id,'alice')"
- f".property({_g(names.name_key)},'Alice')",
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'alice').property({_g(names.name_key)},'Alice')",
)
change_plan = {
"operations": [
@@ -213,10 +199,7 @@ def test_create_edge_rejects_missing_endpoint(hugegraph_client):
)
assert result["valid"] is False
- assert any(
- "target endpoint matched_count must be 1" in error["reason"]
- for error in result["errors"]
- )
+ assert any("target endpoint matched_count must be 1" in error["reason"] for error in result["errors"])
assert (
_count(
hugegraph_client,
@@ -231,18 +214,15 @@ def test_create_edge_rejects_non_unique_property_match(hugegraph_client):
_ensure_custom_id_schema(hugegraph_client, names)
_exec(
hugegraph_client,
- f"g.addV({_g(names.vertex_label)}).property(T.id,'alice_1')"
- f".property({_g(names.name_key)},'Alice')",
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'alice_1').property({_g(names.name_key)},'Alice')",
)
_exec(
hugegraph_client,
- f"g.addV({_g(names.vertex_label)}).property(T.id,'alice_2')"
- f".property({_g(names.name_key)},'Alice')",
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'alice_2').property({_g(names.name_key)},'Alice')",
)
_exec(
hugegraph_client,
- f"g.addV({_g(names.vertex_label)}).property(T.id,'bob')"
- f".property({_g(names.name_key)},'Bob')",
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'bob').property({_g(names.name_key)},'Bob')",
)
change_plan = {
"operations": [
@@ -263,10 +243,7 @@ def test_create_edge_rejects_non_unique_property_match(hugegraph_client):
)
assert result["valid"] is False
- assert any(
- "source endpoint matched_count must be 1" in error["reason"]
- for error in result["errors"]
- )
+ assert any("source endpoint matched_count must be 1" in error["reason"] for error in result["errors"])
assert (
_count(
hugegraph_client,
@@ -276,6 +253,67 @@ def test_create_edge_rejects_non_unique_property_match(hugegraph_client):
)
+def test_create_edge_uses_endpoint_ids_bound_before_predicate_expands(
+ hugegraph_client,
+):
+ names = _schema_names("edge_bound_ids")
+ _ensure_custom_id_schema(hugegraph_client, names)
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'alice-original').property({_g(names.name_key)},'Alice')",
+ )
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'bob').property({_g(names.name_key)},'Bob')",
+ )
+ change_plan = {
+ "operations": [
+ {
+ "op": "create_edge",
+ "label": names.edge_label,
+ "source_label": names.vertex_label,
+ "source_match": {names.name_key: "Alice"},
+ "target_label": names.vertex_label,
+ "target_match": {names.name_key: "Bob"},
+ }
+ ]
+ }
+
+ dry_run = manage_graph_data_module.dry_run_graph_change_plan(
+ change_plan,
+ manage_graph_data_module._fetch_live_schema(),
+ )
+ assert dry_run["valid"] is True
+ operation = dry_run["compiled_plan"]["operations"][0]
+ assert operation["source_id"] == "alice-original"
+ assert operation["target_id"] == "bob"
+
+ # Expand the approved predicate after planning. The executor must retain
+ # the original backend identity instead of matching both Alice vertices.
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'alice-concurrent').property({_g(names.name_key)},'Alice')",
+ )
+ result = manage_graph_data_module.execute_graph_change_plan(dry_run["compiled_plan"])
+
+ assert result["success"] is True
+ assert _count(hugegraph_client, f"g.E().hasLabel({_g(names.edge_label)})") == 1
+ assert (
+ _count(
+ hugegraph_client,
+ f"g.V('alice-original').out({_g(names.edge_label)}).hasId('bob')",
+ )
+ == 1
+ )
+ assert (
+ _count(
+ hugegraph_client,
+ f"g.V('alice-concurrent').out({_g(names.edge_label)})",
+ )
+ == 0
+ )
+
+
def test_create_vertex_existing_id_rejected_in_dry_run_without_writes(
hugegraph_client,
):
@@ -283,8 +321,7 @@ def test_create_vertex_existing_id_rejected_in_dry_run_without_writes(
_ensure_custom_id_schema(hugegraph_client, names)
_exec(
hugegraph_client,
- f"g.addV({_g(names.vertex_label)}).property(T.id,'conflict')"
- f".property({_g(names.name_key)},'Existing')",
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'conflict').property({_g(names.name_key)},'Existing')",
)
graph_data = {
"vertices": [
@@ -307,8 +344,7 @@ def test_create_vertex_existing_id_rejected_in_dry_run_without_writes(
assert dry_run["ok"] is False
assert dry_run["error"]["type"] == "INVALID_GRAPH_DATA"
assert any(
- "create_vertex id identity already exists" in error["reason"]
- for error in dry_run["error"]["details"]["errors"]
+ "create_vertex id identity already exists" in error["reason"] for error in dry_run["error"]["details"]["errors"]
)
assert (
_count(
@@ -360,16 +396,14 @@ def test_create_vertex_existing_primary_key_rejected_in_dry_run_without_writes(
assert (
_count(
hugegraph_client,
- f"g.V().hasLabel({_g(names.vertex_label)})"
- f".has({_g(names.name_key)},'Alice')",
+ f"g.V().hasLabel({_g(names.vertex_label)}).has({_g(names.name_key)},'Alice')",
)
== 0
)
assert (
_count(
hugegraph_client,
- f"g.V().hasLabel({_g(names.vertex_label)})"
- f".has({_g(names.name_key)},'Conflict')",
+ f"g.V().hasLabel({_g(names.vertex_label)}).has({_g(names.name_key)},'Conflict')",
)
== 1
)
@@ -379,32 +413,20 @@ def test_public_delete_edge_and_vertices_real_graph_state_matches(
hugegraph_client,
):
names = _schema_names("delete")
- _ensure_primary_key_schema(hugegraph_client, names)
- graph_data = {
- "vertices": [
- {
- "label": names.vertex_label,
- "properties": {names.name_key: "Alice"},
- },
- {
- "label": names.vertex_label,
- "properties": {names.name_key: "Bob"},
- },
- ],
- "edges": [
- {
- "label": names.edge_label,
- "source_label": names.vertex_label,
- "source": {names.name_key: "Alice"},
- "target_label": names.vertex_label,
- "target": {names.name_key: "Bob"},
- }
- ],
- }
-
- result = _import_graph_data(graph_data)
+ _ensure_custom_id_schema(hugegraph_client, names)
+ alice_id = f"{names.vertex_label}_alice"
+ bob_id = f"{names.vertex_label}_bob"
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(alice_id)}).property({_g(names.name_key)},'Alice')",
+ )
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(bob_id)}).property({_g(names.name_key)},'Bob')",
+ )
+ edge_create = f"g.V({_g(alice_id)}).as('s').V({_g(bob_id)}).addE({_g(names.edge_label)}).from('s')"
+ _exec(hugegraph_client, edge_create)
- assert result["ok"] is True
assert _count(hugegraph_client, f"g.V().hasLabel({_g(names.vertex_label)})") == 2
assert _count(hugegraph_client, f"g.E().hasLabel({_g(names.edge_label)})") == 1
@@ -426,6 +448,15 @@ def test_public_delete_edge_and_vertices_real_graph_state_matches(
assert _count(hugegraph_client, f"g.E().hasLabel({_g(names.edge_label)})") == 0
assert _count(hugegraph_client, f"g.V().hasLabel({_g(names.vertex_label)})") == 2
+ # The canonical plan-id path deletes the same stable edge identity.
+ _exec(hugegraph_client, edge_create)
+ canonical_preview = server.delete_graph_data_tool(change_plan=edge_delete_plan)
+ assert canonical_preview["ok"] is True
+ canonical_result = server.confirm_write_tool(plan_id=canonical_preview["data"]["plan_id"])
+ assert canonical_result["ok"] is True
+ assert canonical_result["data"]["status"] == "APPLIED"
+ assert _count(hugegraph_client, f"g.E().hasLabel({_g(names.edge_label)})") == 0
+
vertex_delete_plan = {
"operations": [
{
@@ -442,40 +473,256 @@ def test_public_delete_edge_and_vertices_real_graph_state_matches(
}
result = _delete_graph_data(vertex_delete_plan)
- assert result["ok"] is True
- assert _count(hugegraph_client, f"g.V().hasLabel({_g(names.vertex_label)})") == 0
+ assert result["ok"] is False
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ assert _count(hugegraph_client, f"g.V().hasLabel({_g(names.vertex_label)})") == 2
-def test_edge_by_id_query_and_mutate_handles_real_hugegraph_edge_id(
+def test_delete_confirmation_uses_dry_run_bound_vertex_id(hugegraph_client):
+ names = _schema_names("bound_delete")
+ _ensure_custom_id_schema(hugegraph_client, names)
+ original_id = f"original_{uuid4().hex}"
+ late_match_id = f"late_match_{uuid4().hex}"
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(original_id)}).property({_g(names.name_key)},'Shared')",
+ )
+ change_plan = {
+ "operations": [
+ {
+ "op": "delete_vertex",
+ "label": names.vertex_label,
+ "match": {names.name_key: "Shared"},
+ "cascade": False,
+ }
+ ]
+ }
+
+ dry_run = server.delete_graph_data_tool(change_plan=change_plan)
+ assert dry_run["ok"] is True
+ assert dry_run["data"]["confirmable"] is False
+ assert dry_run["data"]["preview"][0]["target_id"] == original_id
+
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(late_match_id)}).property({_g(names.name_key)},'Shared')",
+ )
+ context = dry_run["data"]["plan_context"]
+ result = server.delete_graph_data_tool(
+ change_plan=change_plan,
+ dry_run=False,
+ confirm=True,
+ plan_hash=dry_run["data"]["plan_hash"],
+ nonce=context["nonce"],
+ expires_at=context["expires_at"],
+ )
+
+ assert result["ok"] is False
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ assert _count(hugegraph_client, f"g.V({_g(original_id)})") == 1
+ assert _count(hugegraph_client, f"g.V({_g(late_match_id)})") == 1
+
+
+def test_delete_vertex_without_cascade_is_blocked_if_edge_added_after_dry_run(
hugegraph_client,
):
- names = _schema_names("edge_id")
- _ensure_primary_key_schema(hugegraph_client, names)
- graph_data = {
- "vertices": [
+ names = _schema_names("conditional_delete")
+ _ensure_custom_id_schema(hugegraph_client, names)
+ alice_id = f"alice_{uuid4().hex}"
+ bob_id = f"bob_{uuid4().hex}"
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(alice_id)}).property({_g(names.name_key)},'Alice')",
+ )
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(bob_id)}).property({_g(names.name_key)},'Bob')",
+ )
+ change_plan = {
+ "operations": [
{
+ "op": "delete_vertex",
"label": names.vertex_label,
- "properties": {names.name_key: "Alice"},
- },
+ "match": {"id": alice_id},
+ "cascade": False,
+ }
+ ]
+ }
+
+ dry_run = server.delete_graph_data_tool(change_plan=change_plan)
+ assert dry_run["ok"] is True
+ _exec(
+ hugegraph_client,
+ f"g.V({_g(alice_id)}).as('s').V({_g(bob_id)}).addE({_g(names.edge_label)}).from('s').next()",
+ )
+ assert _count(hugegraph_client, f"g.V({_g(alice_id)}).bothE()") == 1
+ context = dry_run["data"]["plan_context"]
+ result = server.delete_graph_data_tool(
+ change_plan=change_plan,
+ dry_run=False,
+ confirm=True,
+ plan_hash=dry_run["data"]["plan_hash"],
+ nonce=context["nonce"],
+ expires_at=context["expires_at"],
+ )
+
+ assert result["ok"] is False
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ assert _count(hugegraph_client, f"g.V({_g(alice_id)})") == 1
+ assert _count(hugegraph_client, f"g.V({_g(bob_id)})") == 1
+ assert _count(hugegraph_client, f"g.E().hasLabel({_g(names.edge_label)})") == 1
+
+
+@pytest.mark.xfail(
+ reason="HugeGraph 1.7.0/RocksDB did not isolate concurrent edge-add and conditional vertex-delete",
+ # This is a stochastic concurrency probe. An XPASS means only that this
+ # run did not observe the known bad interleaving; it is not proof that the
+ # backend provides the isolation capability.
+ strict=False,
+)
+def test_rocksdb_isolates_conditional_vertex_delete_from_concurrent_edge_add(
+ hugegraph_client,
+):
+ """Probe the HugeGraph 1.7.0/RocksDB delete capability profile.
+
+ A client-side check followed by a delete cannot establish this property. Both
+ writes must race as separate server requests, released by the same barrier,
+ and every committed outcome must be one of the two serializable outcomes:
+
+ * delete wins, so edge creation fails and the target vertex is absent; or
+ * edge creation wins, so ``not(bothE())`` prevents the vertex deletion.
+
+ In particular, a successful edge creation followed by the vertex and edge
+ disappearing would be an implicit cascade and must fail this test.
+ """
+
+ names = _schema_names("isolated_delete")
+ _ensure_custom_id_schema(hugegraph_client, names)
+ iterations = int(_env("MCP_ISOLATED_DELETE_STRESS_ITERATIONS", "50"))
+
+ def race_write(query: str, start: Barrier) -> tuple[object | None, str | None]:
+ client = build_hugegraph_client(MCPConfig.from_env(), client_cls=PyHugeClient)
+ start.wait(timeout=10.0)
+ try:
+ result = _extract_count(_exec(client, query))
+ except Exception as exc: # noqa: BLE001 - record concurrent backend outcome
+ return None, str(exc)
+ return result, None
+
+ violations: list[str] = []
+ for iteration in range(iterations):
+ alice_id = f"isolated_alice_{iteration}_{uuid4().hex}"
+ bob_id = f"isolated_bob_{iteration}_{uuid4().hex}"
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(alice_id)})"
+ f".property({_g(names.name_key)},'Alice').iterate()",
+ )
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(bob_id)})"
+ f".property({_g(names.name_key)},'Bob').iterate()",
+ )
+
+ start = Barrier(3)
+ # count() returns 1 only when addE actually produced an edge. An empty
+ # traversal may complete without an exception after the concurrent
+ # delete; treating that as a successful create would be false evidence
+ # of an implicit cascade.
+ add_query = f"g.V({_g(alice_id)}).as('s').V({_g(bob_id)}).addE({_g(names.edge_label)}).from('s').count()"
+ delete_query = f"g.V({_g(alice_id)}).not(bothE()).drop().iterate(); true"
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ add_future = pool.submit(race_write, add_query, start)
+ delete_future = pool.submit(race_write, delete_query, start)
+ start.wait(timeout=10.0)
+ add_affected, add_error = add_future.result(timeout=30.0)
+ delete_completed, delete_error = delete_future.result(timeout=30.0)
+
+ alice_count = _count(hugegraph_client, f"g.V({_g(alice_id)})")
+ bob_count = _count(hugegraph_client, f"g.V({_g(bob_id)})")
+ edge_count = _count(
+ hugegraph_client,
+ f"g.V({_g(alice_id)}).outE({_g(names.edge_label)}).where(inV().hasId({_g(bob_id)}))",
+ )
+ observed = (add_affected, delete_completed, alice_count, bob_count, edge_count)
+ allowed = {
+ (None, True, 0, 1, 0),
+ (0, True, 0, 1, 0),
+ (1, True, 1, 1, 1),
+ }
+ if observed not in allowed:
+ violations.append(
+ f"iteration {iteration}: {observed}; add_error={add_error!r}, delete_error={delete_error!r}"
+ )
+
+ # Each iteration has unique IDs, but explicit cleanup keeps the stress
+ # test bounded when the edge-add outcome wins repeatedly.
+ _exec(hugegraph_client, f"g.V({_g(alice_id)},{_g(bob_id)}).drop().iterate()")
+
+ assert not violations, f"{len(violations)} non-isolated outcomes; first 10:\n" + "\n".join(violations[:10])
+
+
+def test_dry_run_rejects_edge_whose_target_vertex_is_created_later(
+ hugegraph_client,
+):
+ names = _schema_names("future_endpoint")
+ _ensure_custom_id_schema(hugegraph_client, names)
+ change_plan = {
+ "operations": [
{
+ "op": "create_vertex",
"label": names.vertex_label,
- "properties": {names.name_key: "Bob"},
+ "id": "alice",
+ "properties": {names.name_key: "Alice"},
},
- ],
- "edges": [
{
+ "op": "create_edge",
"label": names.edge_label,
"source_label": names.vertex_label,
- "source": {names.name_key: "Alice"},
+ "source_match": {"id": "alice"},
"target_label": names.vertex_label,
- "target": {names.name_key: "Bob"},
- }
- ],
+ "target_match": {"id": "bob"},
+ },
+ {
+ "op": "create_vertex",
+ "label": names.vertex_label,
+ "id": "bob",
+ "properties": {names.name_key: "Bob"},
+ },
+ ]
}
- result = _import_graph_data(graph_data)
+ result = manage_graph_data_module.dry_run_graph_change_plan(
+ change_plan,
+ manage_graph_data_module._fetch_live_schema(),
+ )
- assert result["ok"] is True
+ assert result["valid"] is False
+ assert result["preview"][1]["source_planned_count"] == 1
+ assert result["preview"][1]["target_planned_count"] == 0
+ assert any("target endpoint matched_count must be 1, got 0" in error["reason"] for error in result["errors"])
+ assert _count(hugegraph_client, f"g.V().hasLabel({_g(names.vertex_label)})") == 0
+
+
+def test_edge_by_id_query_and_mutate_handles_real_hugegraph_edge_id(
+ hugegraph_client,
+):
+ names = _schema_names("edge_id")
+ _ensure_custom_id_schema(hugegraph_client, names)
+ alice_id = f"{names.vertex_label}_alice"
+ bob_id = f"{names.vertex_label}_bob"
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(alice_id)}).property({_g(names.name_key)},'Alice')",
+ )
+ _exec(
+ hugegraph_client,
+ f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(bob_id)}).property({_g(names.name_key)},'Bob')",
+ )
+ _exec(
+ hugegraph_client,
+ f"g.V({_g(alice_id)}).as('s').V({_g(bob_id)}).addE({_g(names.edge_label)}).from('s')",
+ )
edge_id = _edge_id(hugegraph_client, names)
assert ">" in edge_id
@@ -496,6 +743,7 @@ def test_edge_by_id_query_and_mutate_handles_real_hugegraph_edge_id(
)
assert dry_run["ok"] is True
plan_context = dry_run["data"]["plan_context"]
+ before = hugegraph_client.graph().getEdgeById(edge_id).properties
apply_result = server.mutate_graph_properties_tool(
target="edge",
operation="append",
@@ -508,43 +756,10 @@ def test_edge_by_id_query_and_mutate_handles_real_hugegraph_edge_id(
expires_at=plan_context["expires_at"],
)
- assert apply_result["ok"] is True
- query_result = server.query_graph_data_tool(
- target="edge",
- operation="get_by_id",
- id=edge_id,
- )
- assert (
- query_result["data"]["items"][0]["properties"][names.name_key] == "friendship"
- )
-
- dry_run = server.mutate_graph_properties_tool(
- target="edge",
- operation="eliminate",
- id=edge_id,
- properties={names.name_key: "friendship"},
- )
- assert dry_run["ok"] is True
- plan_context = dry_run["data"]["plan_context"]
- apply_result = server.mutate_graph_properties_tool(
- target="edge",
- operation="eliminate",
- id=edge_id,
- properties={names.name_key: "friendship"},
- dry_run=False,
- confirm=True,
- plan_hash=dry_run["data"]["plan_hash"],
- nonce=plan_context["nonce"],
- expires_at=plan_context["expires_at"],
- )
-
- assert apply_result["ok"] is True
- query_result = server.query_graph_data_tool(
- target="edge",
- operation="get_by_id",
- id=edge_id,
- )
- assert names.name_key not in query_result["data"]["items"][0]["properties"]
+ assert apply_result["ok"] is False
+ assert apply_result["error"]["type"] == "FEATURE_DISABLED"
+ assert apply_result["error"]["details"]["write_attempted"] is False
+ assert hugegraph_client.graph().getEdgeById(edge_id).properties == before
def test_collection_append_matches_real_vertex_list_and_edge_set(hugegraph_client):
@@ -590,8 +805,10 @@ def test_collection_append_matches_real_vertex_list_and_edge_set(hugegraph_clien
nonce=vertex_context["nonce"],
expires_at=vertex_context["expires_at"],
)
- assert vertex_result["ok"] is True
- assert graph.getVertexById(alice.id).properties[names.list_key] == ["a", "b", "b"]
+ assert vertex_result["ok"] is False
+ assert vertex_result["error"]["type"] == "FEATURE_DISABLED"
+ assert vertex_result["error"]["details"]["write_attempted"] is False
+ assert graph.getVertexById(alice.id).properties[names.list_key] == ["a"]
edge_dry_run = server.mutate_graph_properties_tool(
target="edge",
@@ -613,11 +830,13 @@ def test_collection_append_matches_real_vertex_list_and_edge_set(hugegraph_clien
nonce=edge_context["nonce"],
expires_at=edge_context["expires_at"],
)
- assert edge_result["ok"] is True
- assert set(graph.getEdgeById(edge.id).properties[names.set_key]) == {"a", "b"}
+ assert edge_result["ok"] is False
+ assert edge_result["error"]["type"] == "FEATURE_DISABLED"
+ assert edge_result["error"]["details"]["write_attempted"] is False
+ assert set(graph.getEdgeById(edge.id).properties[names.set_key]) == {"a"}
-def test_partial_write_returns_error_envelope_and_real_graph_state_matches(
+def test_import_partial_scenario_is_preview_only_and_has_no_side_effects(
hugegraph_client,
):
names = _schema_names("partial")
@@ -639,6 +858,7 @@ def test_partial_write_returns_error_envelope_and_real_graph_state_matches(
}
dry_run = server.import_graph_data_tool(mode="ingest", graph_data=graph_data)
assert dry_run["ok"] is True
+ assert dry_run["data"]["confirmable"] is False
plan_context = dry_run["data"]["plan_context"]
@@ -653,19 +873,18 @@ def test_partial_write_returns_error_envelope_and_real_graph_state_matches(
)
assert result["ok"] is False
- assert result["error"]["details"]["status"] == "partial"
+ assert result["error"]["type"] == "FEATURE_DISABLED"
assert (
_count(
hugegraph_client,
f"g.V().hasLabel({_g(names.vertex_label)}).hasId('alice')",
)
- == 1
+ == 0
)
assert (
_count(
hugegraph_client,
- f"g.V().hasLabel({_g(names.vertex_label)})"
- f".hasId('conflict').has({_g(names.name_key)},'Duplicate')",
+ f"g.V().hasLabel({_g(names.vertex_label)}).hasId('conflict').has({_g(names.name_key)},'Duplicate')",
)
== 0
)
@@ -718,8 +937,7 @@ def test_admin_write_tool_gate_prevents_real_write(hugegraph_client, monkeypatch
result = server.execute_gremlin_write_tool(
gremlin_query=(
- f"g.addV({_g(names.vertex_label)}).property(T.id,'admin_blocked')"
- f".property({_g(names.name_key)},'Blocked')"
+ f"g.addV({_g(names.vertex_label)}).property(T.id,'admin_blocked').property({_g(names.name_key)},'Blocked')"
)
)
@@ -736,7 +954,7 @@ def test_admin_write_tool_gate_prevents_real_write(hugegraph_client, monkeypatch
def _import_graph_data(graph_data: dict) -> dict:
dry_run = server.import_graph_data_tool(mode="ingest", graph_data=graph_data)
- assert dry_run["ok"] is True
+ assert dry_run["ok"] is True, dry_run
plan_context = dry_run["data"]["plan_context"]
return server.import_graph_data_tool(
mode="ingest",
@@ -749,6 +967,21 @@ def _import_graph_data(graph_data: dict) -> dict:
)
+def _apply_schema_operation(operation: dict) -> dict:
+ operations = [operation]
+ dry_run = server.apply_schema_tool(mode="dry_run", operations=operations)
+ assert dry_run["ok"] is True
+ assert dry_run["data"]["valid"] is True
+ plan_id = dry_run["data"]["plan_id"]
+ assert plan_id.startswith("wp_")
+ result = server.confirm_write_tool(plan_id=plan_id)
+ assert result["ok"] is True, result
+ status = server.get_write_status_tool(plan_id=plan_id)
+ assert status["ok"] is True
+ assert status["data"] == result["data"]
+ return result
+
+
def _delete_graph_data(change_plan: dict) -> dict:
dry_run = server.delete_graph_data_tool(change_plan=change_plan)
assert dry_run["ok"] is True
@@ -786,15 +1019,13 @@ def _ensure_custom_id_schema(
) -> None:
schema = client.schema()
schema.propertyKey(names.name_key).asText().ifNotExist().create()
- schema.vertexLabel(names.vertex_label).properties(
+ schema.vertexLabel(names.vertex_label).properties(names.name_key).useCustomizeStringId().nullableKeys(
names.name_key
- ).useCustomizeStringId().nullableKeys(names.name_key).ifNotExist().create()
- schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel(
- names.vertex_label
- ).properties(names.name_key).nullableKeys(names.name_key).ifNotExist().create()
- index = (
- schema.indexLabel(names.name_index).onV(names.vertex_label).by(names.name_key)
- )
+ ).ifNotExist().create()
+ schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel(names.vertex_label).properties(
+ names.name_key
+ ).nullableKeys(names.name_key).ifNotExist().create()
+ index = schema.indexLabel(names.name_index).onV(names.vertex_label).by(names.name_key)
if unique_name:
index.unique().ifNotExist().create()
else:
@@ -805,12 +1036,10 @@ def _ensure_custom_id_schema(
def _ensure_primary_key_schema(client, names: _Names) -> None:
schema = client.schema()
schema.propertyKey(names.name_key).asText().ifNotExist().create()
- schema.vertexLabel(names.vertex_label).properties(names.name_key).primaryKeys(
+ schema.vertexLabel(names.vertex_label).properties(names.name_key).primaryKeys(names.name_key).ifNotExist().create()
+ schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel(names.vertex_label).properties(
names.name_key
- ).ifNotExist().create()
- schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel(
- names.vertex_label
- ).properties(names.name_key).nullableKeys(names.name_key).ifNotExist().create()
+ ).nullableKeys(names.name_key).ifNotExist().create()
_wait_for_schema_visibility(client, names)
@@ -819,12 +1048,12 @@ def _ensure_collection_schema(client, names: _Names) -> None:
schema.propertyKey(names.name_key).asText().ifNotExist().create()
schema.propertyKey(names.list_key).asText().valueList().ifNotExist().create()
schema.propertyKey(names.set_key).asText().valueSet().ifNotExist().create()
- schema.vertexLabel(names.vertex_label).properties(
- names.name_key, names.list_key
- ).primaryKeys(names.name_key).nullableKeys(names.list_key).ifNotExist().create()
- schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel(
- names.vertex_label
- ).properties(names.set_key).nullableKeys(names.set_key).ifNotExist().create()
+ schema.vertexLabel(names.vertex_label).properties(names.name_key, names.list_key).primaryKeys(
+ names.name_key
+ ).nullableKeys(names.list_key).ifNotExist().create()
+ schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel(names.vertex_label).properties(
+ names.set_key
+ ).nullableKeys(names.set_key).ifNotExist().create()
_wait_for_schema_visibility(
client,
names,
@@ -859,24 +1088,14 @@ def _wait_for_schema_visibility(
while time.monotonic() < deadline:
try:
schema = client.schema().getSchema()
- schema_payload = (
- schema.get("schema", schema) if isinstance(schema, dict) else {}
- )
+ schema_payload = schema.get("schema", schema) if isinstance(schema, dict) else {}
property_keys = {
- item.get("name")
- for item in schema_payload.get("propertykeys", [])
- if isinstance(item, dict)
+ item.get("name") for item in schema_payload.get("propertykeys", []) if isinstance(item, dict)
}
vertex_labels = {
- item.get("name")
- for item in schema_payload.get("vertexlabels", [])
- if isinstance(item, dict)
- }
- edge_labels = {
- item.get("name")
- for item in schema_payload.get("edgelabels", [])
- if isinstance(item, dict)
+ item.get("name") for item in schema_payload.get("vertexlabels", []) if isinstance(item, dict)
}
+ edge_labels = {item.get("name") for item in schema_payload.get("edgelabels", []) if isinstance(item, dict)}
if (
(required_property_keys or {names.name_key}) <= property_keys
and names.vertex_label in vertex_labels
@@ -887,10 +1106,7 @@ def _wait_for_schema_visibility(
except Exception as exc: # noqa: BLE001 - retry until service is ready
last_error = exc
time.sleep(0.1)
- pytest.fail(
- "Timed out waiting for HugeGraph schema visibility: "
- f"{names.vertex_label}, last_error={last_error}"
- )
+ pytest.fail(f"Timed out waiting for HugeGraph schema visibility: {names.vertex_label}, last_error={last_error}")
def _write_and_drop_schema_probe(
@@ -920,11 +1136,7 @@ def _extract_count(data):
def _user_data_without_server_metadata(value):
if not isinstance(value, dict):
return None
- return {
- key: item
- for key, item in value.items()
- if not (isinstance(key, str) and key.startswith("~"))
- }
+ return {key: item for key, item in value.items() if not (isinstance(key, str) and key.startswith("~"))}
def _env(name: str, default: str | None = None) -> str | None:
diff --git a/hugegraph-mcp/tests/test_confirm_write.py b/hugegraph-mcp/tests/test_confirm_write.py
new file mode 100644
index 00000000..88b210f0
--- /dev/null
+++ b/hugegraph-mcp/tests/test_confirm_write.py
@@ -0,0 +1,243 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+import sqlite3
+import time
+from concurrent.futures import ThreadPoolExecutor
+from threading import Barrier, Event, Lock
+
+import pytest
+from hugegraph_mcp.envelope import envelope_ok
+from hugegraph_mcp.plan_store import PlanTransitionError, SQLitePlanStore
+from hugegraph_mcp.reconciler import ReconcileReaderRegistry, WriteReconciler
+from hugegraph_mcp.server import confirm_write_tool, get_write_status_tool
+from hugegraph_mcp.write_executor import WriteExecutor, WriteExecutorRegistry
+from hugegraph_mcp.write_plan import (
+ ApplyReceipt,
+ ApplyStatus,
+ GraphTarget,
+ OperationPlan,
+ PlanStatus,
+ WritePlan,
+)
+
+
+def _plan() -> WritePlan:
+ return WritePlan(
+ plan_id="wp-confirm",
+ tool_name="test",
+ graph_target=GraphTarget("http://127.0.0.1:8080", "hugegraph", "DEFAULT"),
+ principal="admin",
+ operations=(
+ OperationPlan(
+ operation_id="op-confirm",
+ kind="TEST_APPLY",
+ target={"id": "v-1"},
+ expected_state={"value": 1},
+ desired_state={"value": 2},
+ ),
+ ),
+ payload_digest="digest",
+ schema_fingerprint=None,
+ status=PlanStatus.ISSUED,
+ created_at=100,
+ expires_at=4_102_444_800,
+ )
+
+
+def test_repeated_confirmation_returns_persisted_status_without_second_attempt(
+ tmp_path,
+):
+ store = SQLitePlanStore(tmp_path)
+ store.save_plan(_plan())
+ calls = []
+ registry = WriteExecutorRegistry()
+
+ def apply(plan, operation, attempt):
+ calls.append((plan.plan_id, operation.operation_id, attempt))
+ return ApplyReceipt(
+ plan_id=plan.plan_id,
+ operation_id=operation.operation_id,
+ status=ApplyStatus.APPLIED,
+ observed_state={"value": 2},
+ attempt=attempt,
+ committed_at=150,
+ )
+
+ registry.register("TEST_APPLY", apply)
+ executor = WriteExecutor(store=store, registry=registry)
+
+ first = executor.confirm("wp-confirm")
+ second = executor.confirm("wp-confirm")
+
+ assert first["ok"] is True
+ assert second["ok"] is True
+ assert first["data"]["status"] == "APPLIED"
+ assert second["data"]["status"] == "APPLIED"
+ assert calls == [("wp-confirm", "op-confirm", 1)]
+
+
+def test_concurrent_confirmation_has_one_adapter_attempt(tmp_path):
+ store = SQLitePlanStore(tmp_path)
+ store.save_plan(_plan())
+ registry = WriteExecutorRegistry()
+ calls = 0
+ calls_lock = Lock()
+
+ def apply(plan, operation, attempt):
+ nonlocal calls
+ with calls_lock:
+ calls += 1
+ return ApplyReceipt(
+ plan_id=plan.plan_id,
+ operation_id=operation.operation_id,
+ status=ApplyStatus.APPLIED,
+ attempt=attempt,
+ committed_at=150,
+ )
+
+ registry.register("TEST_APPLY", apply)
+ executor = WriteExecutor(store=store, registry=registry)
+ with ThreadPoolExecutor(max_workers=8) as pool:
+ results = list(pool.map(lambda _: executor.confirm("wp-confirm"), range(8)))
+
+ assert calls == 1
+ assert all(
+ (result["data"]["status"] if result["ok"] else result["error"]["details"]["status"]) in {"APPLIED", "EXECUTING"}
+ for result in results
+ )
+ assert executor.status("wp-confirm")["data"]["status"] == "APPLIED"
+
+
+def test_expired_executor_is_fenced_from_overwriting_new_attempt(tmp_path):
+ store = SQLitePlanStore(tmp_path)
+ store.save_plan(_plan())
+ entered = Barrier(2)
+ release_old = Event()
+ old_registry = WriteExecutorRegistry()
+
+ def delayed_old(plan, operation, attempt):
+ entered.wait()
+ assert release_old.wait(timeout=5)
+ return ApplyReceipt(
+ plan_id=plan.plan_id,
+ operation_id=operation.operation_id,
+ status=ApplyStatus.CONFLICT,
+ attempt=attempt,
+ )
+
+ old_registry.register("TEST_APPLY", delayed_old)
+ old_executor = WriteExecutor(store=store, registry=old_registry)
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ old_result = pool.submit(old_executor.confirm, "wp-confirm")
+ entered.wait()
+ with sqlite3.connect(store.database_path) as connection:
+ connection.execute("UPDATE write_plans SET lease_expires_at = 0 WHERE plan_id = 'wp-confirm'")
+
+ readers = ReconcileReaderRegistry()
+ readers.register(
+ "TEST_APPLY",
+ lambda _plan, _operation: {"value": 1},
+ )
+ reconciled = WriteReconciler(store=store, readers=readers).reconcile("wp-confirm")
+ assert reconciled["data"]["status"] == "UNKNOWN"
+ assert reconciled["data"]["operations"][0]["receipt"]["reason_code"] == ("IN_FLIGHT_COMMIT_NOT_EXCLUDED")
+ with pytest.raises(PlanTransitionError):
+ WriteExecutor(store=store, registry=WriteExecutorRegistry()).resume("wp-confirm")
+ release_old.set()
+ with pytest.raises(PlanTransitionError, match="stale or unfenced"):
+ old_result.result()
+
+ record = store.get_plan_record("wp-confirm")
+ assert record is not None
+ assert record["status"] == "UNKNOWN"
+ assert record["operations"][0]["attempt"] == 1
+
+
+def test_claiming_each_operation_renews_lease_and_blocks_reconcile(tmp_path):
+ store = SQLitePlanStore(tmp_path)
+ store.save_plan(_plan())
+ owner = "executor-owner"
+ store.claim_plan("wp-confirm", owner_token=owner, lease_seconds=10)
+ with sqlite3.connect(store.database_path) as connection:
+ connection.execute(
+ "UPDATE write_plans SET lease_expires_at = strftime('%s','now') + 1 WHERE plan_id = 'wp-confirm'"
+ )
+
+ store.claim_operation(
+ "wp-confirm",
+ "op-confirm",
+ owner_token=owner,
+ attempt_token="attempt-one",
+ lease_seconds=40,
+ )
+
+ record = store.get_plan_record("wp-confirm")
+ assert record is not None
+ assert record["lease_expires_at"] >= int(time.time()) + 39
+ result = WriteReconciler(
+ store=store,
+ readers=ReconcileReaderRegistry(),
+ ).reconcile("wp-confirm")
+ assert result["error"]["details"]["reason_code"] == "EXECUTION_LEASE_ACTIVE"
+
+
+def test_missing_adapter_does_not_claim_plan(tmp_path):
+ store = SQLitePlanStore(tmp_path)
+ store.save_plan(_plan())
+ executor = WriteExecutor(store=store, registry=WriteExecutorRegistry())
+
+ result = executor.confirm("wp-confirm")
+
+ assert result["ok"] is False
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ assert store.get_plan("wp-confirm").status is PlanStatus.ISSUED
+
+
+def test_status_preserves_active_executing_lease_and_redacts_payload(tmp_path):
+ store = SQLitePlanStore(tmp_path)
+ store.save_plan(_plan())
+ store.claim_plan("wp-confirm")
+ executor = WriteExecutor(store=store, registry=WriteExecutorRegistry())
+
+ result = executor.status("wp-confirm")
+
+ assert result["ok"] is True
+ assert result["data"]["status"] == "EXECUTING"
+ assert result["data"]["reconciliation_required"] is False
+ assert "payload" not in result["data"]
+ assert "payload" not in result["data"]["operations"][0]
+
+
+def test_public_confirm_tool_routes_only_plan_id(monkeypatch):
+ expected = envelope_ok({"plan_id": "wp-confirm", "status": "APPLIED"})
+ calls = []
+
+ def fake_confirm(plan_id):
+ calls.append(plan_id)
+ return expected
+
+ monkeypatch.setattr("hugegraph_mcp.server.confirm_write", fake_confirm)
+
+ result = confirm_write_tool(plan_id="wp-confirm")
+
+ assert result["ok"] is True
+ assert calls == ["wp-confirm"]
+
+
+def test_public_status_tool_routes_only_plan_id(monkeypatch):
+ expected = envelope_ok({"plan_id": "wp-confirm", "status": "UNKNOWN"})
+ calls = []
+
+ def fake_status(plan_id):
+ calls.append(plan_id)
+ return expected
+
+ monkeypatch.setattr("hugegraph_mcp.server.get_write_status", fake_status)
+
+ result = get_write_status_tool(plan_id="wp-confirm")
+
+ assert result["ok"] is True
+ assert calls == ["wp-confirm"]
diff --git a/hugegraph-mcp/tests/test_graph_write_workflow.py b/hugegraph-mcp/tests/test_graph_write_workflow.py
new file mode 100644
index 00000000..2ef4ea7a
--- /dev/null
+++ b/hugegraph-mcp/tests/test_graph_write_workflow.py
@@ -0,0 +1,408 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+from __future__ import annotations
+
+from hugegraph_mcp.config import MCPConfig
+from hugegraph_mcp.plan_hash import PlanContext
+from hugegraph_mcp.plan_store import SQLitePlanStore
+from hugegraph_mcp.reconciler import reconcile_operation_state
+from hugegraph_mcp.tools import graph_write_adapter
+from hugegraph_mcp.tools.graph_write_plan import (
+ CREATE_EDGE,
+ CREATE_VERTEX,
+ DELETE_EDGE,
+ compile_graph_write_plan,
+)
+from hugegraph_mcp.tools.manage_graph_data import manage_graph_data
+from hugegraph_mcp.write_executor import (
+ DEFAULT_WRITE_EXECUTOR_REGISTRY,
+ WriteExecutor,
+ confirm_write,
+)
+from hugegraph_mcp.write_plan import ApplyStatus
+
+
+def _schema():
+ return {
+ "schema": {
+ "propertykeys": [{"name": "name", "data_type": "TEXT"}],
+ "vertexlabels": [
+ {
+ "name": "person",
+ "properties": ["name"],
+ "primary_keys": ["name"],
+ }
+ ],
+ "edgelabels": [
+ {
+ "name": "knows",
+ "source_label": "person",
+ "target_label": "person",
+ "frequency": "SINGLE",
+ "properties": [],
+ }
+ ],
+ }
+ }
+
+
+def _context() -> PlanContext:
+ cfg = MCPConfig.from_env()
+ return PlanContext(
+ tool_name="manage_graph_data",
+ mode="import",
+ graph_url=cfg.url,
+ graph_name=cfg.graph,
+ graphspace=cfg.graphspace or "DEFAULT",
+ principal=cfg.user,
+ readonly=False,
+ payload_digest="legacy-digest",
+ schema_hash="schema",
+ nonce="nonce",
+ expires_at=4_102_444_800,
+ )
+
+
+def _import_plan():
+ return compile_graph_write_plan(
+ {
+ "operations": [
+ {
+ "op": "create_vertex",
+ "label": "person",
+ "properties": {"name": "Alice"},
+ },
+ {
+ "op": "create_vertex",
+ "label": "person",
+ "properties": {"name": "Bob"},
+ },
+ {
+ "op": "create_edge",
+ "label": "knows",
+ "source_label": "person",
+ "target_label": "person",
+ "source_operation_index": 0,
+ "target_operation_index": 1,
+ "source_match": {"name": "Alice"},
+ "target_match": {"name": "Bob"},
+ "properties": {},
+ },
+ ]
+ },
+ plan_context=_context(),
+ live_schema=_schema(),
+ )
+
+
+def test_import_compiles_ordered_receipt_dependencies():
+ plan = _import_plan()
+
+ assert [operation.kind for operation in plan.operations] == [
+ CREATE_VERTEX,
+ CREATE_VERTEX,
+ CREATE_EDGE,
+ ]
+ edge = plan.operations[2]
+ assert edge.depends_on == (
+ plan.operations[0].operation_id,
+ plan.operations[1].operation_id,
+ )
+ assert edge.target["source"] == {"operation_id": plan.operations[0].operation_id}
+ assert "source_match" not in edge.target["source"]
+
+
+def test_default_registry_fails_closed_for_unverified_graph_creates():
+ assert DEFAULT_WRITE_EXECUTOR_REGISTRY.adapter_for(CREATE_VERTEX) is None
+ assert DEFAULT_WRITE_EXECUTOR_REGISTRY.adapter_for(CREATE_EDGE) is None
+ assert DEFAULT_WRITE_EXECUTOR_REGISTRY.adapter_for(DELETE_EDGE) is not None
+
+
+def test_executor_persists_vertex_ids_before_dependent_edge(monkeypatch, tmp_path):
+ plan = _import_plan()
+ store = SQLitePlanStore(tmp_path / "state")
+ store.save_plan(plan)
+ monkeypatch.setattr(graph_write_adapter, "plan_store_from_config", lambda: store)
+ state = {"vertices": {}, "edge": None}
+
+ def fake_read(query):
+ if ".outE('knows')" in query:
+ data = [state["edge"]] if state["edge"] is not None else []
+ elif query.startswith("g.V('alice-id')"):
+ data = [state["vertices"]["Alice"]] if "Alice" in state["vertices"] else []
+ elif query.startswith("g.V('bob-id')"):
+ data = [state["vertices"]["Bob"]] if "Bob" in state["vertices"] else []
+ elif ".has('name','Alice')" in query:
+ data = [state["vertices"]["Alice"]] if "Alice" in state["vertices"] else []
+ elif ".has('name','Bob')" in query:
+ data = [state["vertices"]["Bob"]] if "Bob" in state["vertices"] else []
+ else:
+ data = []
+ return {"ok": True, "data": {"data": data}}
+
+ def fake_write(query, **_kwargs):
+ if "addV('person')" in query and "'Alice'" in query:
+ state["vertices"]["Alice"] = {
+ "id": "alice-id",
+ "label": "person",
+ "name": "Alice",
+ }
+ elif "addV('person')" in query and "'Bob'" in query:
+ state["vertices"]["Bob"] = {
+ "id": "bob-id",
+ "label": "person",
+ "name": "Bob",
+ }
+ elif ".addE('knows')" in query:
+ assert "g.V('alice-id')" in query
+ assert ".V('bob-id')" in query
+ state["edge"] = {"id": "edge-id", "label": "knows"}
+ return {"ok": True, "data": {"affected": 1}}
+
+ monkeypatch.setattr(graph_write_adapter.gremlin_tools, "execute_gremlin_read", fake_read)
+ monkeypatch.setattr(graph_write_adapter.gremlin_tools, "execute_gremlin_write", fake_write)
+
+ result = WriteExecutor(
+ store=store,
+ registry=graph_write_adapter_registry(),
+ ).confirm(plan.plan_id)
+
+ assert result["ok"] is True
+ assert result["data"]["status"] == "APPLIED"
+ record = store.get_plan_record(plan.plan_id)
+ assert record is not None
+ assert record["operations"][0]["receipt"]["observed_state"]["id"] == "alice-id"
+ assert record["operations"][1]["receipt"]["observed_state"]["id"] == "bob-id"
+ assert record["operations"][2]["receipt"]["observed_state"]["id"] == "edge-id"
+
+
+def test_create_reconcile_classifies_exact_missing_conflict_and_duplicates(monkeypatch):
+ vertex = _import_plan().operations[0]
+ responses = iter(
+ [
+ [{"id": "v1", "label": "person", "name": "Alice"}],
+ [],
+ [{"id": "v1", "label": "person", "name": "Mallory"}],
+ [
+ {"id": "v1", "label": "person", "name": "Alice"},
+ {"id": "v2", "label": "person", "name": "Alice"},
+ ],
+ ]
+ )
+ monkeypatch.setattr(
+ graph_write_adapter.gremlin_tools,
+ "execute_gremlin_read",
+ lambda _query: {"ok": True, "data": {"data": next(responses)}},
+ )
+
+ exact = graph_write_adapter.create_vertex_reader(_import_plan(), vertex)
+ missing = graph_write_adapter.create_vertex_reader(_import_plan(), vertex)
+ conflict = graph_write_adapter.create_vertex_reader(_import_plan(), vertex)
+ duplicate = graph_write_adapter.create_vertex_reader(_import_plan(), vertex)
+
+ assert exact == vertex.desired_state
+ assert missing == vertex.expected_state
+ assert conflict["properties"] == {"name": "Mallory"}
+ assert duplicate["ambiguous"] is True
+
+
+def test_create_edge_reconcile_rejects_indistinguishable_duplicates(monkeypatch):
+ plan = compile_graph_write_plan(
+ {
+ "operations": [
+ {
+ "op": "create_edge",
+ "label": "knows",
+ "source_label": "person",
+ "target_label": "person",
+ "source_id": "alice-id",
+ "target_id": "bob-id",
+ "properties": {},
+ }
+ ]
+ },
+ plan_context=_context(),
+ live_schema=_schema(),
+ )
+ edge = plan.operations[0]
+ responses = iter(
+ [
+ [{"id": "e1", "label": "knows"}],
+ [],
+ [{"id": "e1", "label": "other"}],
+ [
+ {"id": "e1", "label": "knows"},
+ {"id": "e2", "label": "knows"},
+ ],
+ ]
+ )
+ monkeypatch.setattr(
+ graph_write_adapter.gremlin_tools,
+ "execute_gremlin_read",
+ lambda _query: {"ok": True, "data": {"data": next(responses)}},
+ )
+
+ assert graph_write_adapter.create_edge_reader(plan, edge) == edge.desired_state
+ assert graph_write_adapter.create_edge_reader(plan, edge) == edge.expected_state
+ assert graph_write_adapter.create_edge_reader(plan, edge)["label"] == "other"
+ assert graph_write_adapter.create_edge_reader(plan, edge)["ambiguous"] is True
+
+
+def test_delete_edge_receipt_and_reader_keep_stable_backend_identity(
+ monkeypatch,
+):
+ plan = compile_graph_write_plan(
+ {
+ "operations": [
+ {
+ "op": "delete_edge",
+ "label": "knows",
+ "source_label": "person",
+ "target_label": "person",
+ "source_match": {"id": "alice-id"},
+ "target_match": {"id": "bob-id"},
+ "target_id": "edge-id",
+ }
+ ]
+ },
+ plan_context=_context(),
+ live_schema=_schema(),
+ )
+ operation = plan.operations[0]
+ assert operation.kind == DELETE_EDGE
+ responses = iter([[{"id": "edge-id", "label": "knows"}], []])
+ monkeypatch.setattr(
+ graph_write_adapter.gremlin_tools,
+ "execute_gremlin_read",
+ lambda _query: {"ok": True, "data": {"data": next(responses)}},
+ )
+ writes = []
+ monkeypatch.setattr(
+ graph_write_adapter.gremlin_tools,
+ "execute_gremlin_write",
+ lambda query, **_kwargs: writes.append(query) or {"ok": True, "data": {"affected": 1}},
+ )
+
+ receipt = graph_write_adapter.delete_edge_adapter(plan, operation, 1)
+
+ assert receipt.status is ApplyStatus.APPLIED
+ assert receipt.observed_state == {
+ "exists": False,
+ "element_type": "edge",
+ "id": "edge-id",
+ "label": "knows",
+ }
+ assert writes == ["g.E('edge-id').drop()"]
+
+
+def test_delete_edge_reconcile_distinguishes_absent_expected_and_conflict(
+ monkeypatch,
+):
+ plan = compile_graph_write_plan(
+ {
+ "operations": [
+ {
+ "op": "delete_edge",
+ "label": "knows",
+ "source_label": "person",
+ "target_label": "person",
+ "source_match": {"id": "alice-id"},
+ "target_match": {"id": "bob-id"},
+ "target_id": "edge-id",
+ }
+ ]
+ },
+ plan_context=_context(),
+ live_schema=_schema(),
+ )
+ operation = plan.operations[0]
+ responses = iter(
+ [
+ [],
+ [{"id": "edge-id", "label": "knows"}],
+ [{"id": "edge-id", "label": "other"}],
+ ]
+ )
+ monkeypatch.setattr(
+ graph_write_adapter.gremlin_tools,
+ "execute_gremlin_read",
+ lambda _query: {"ok": True, "data": {"data": next(responses)}},
+ )
+
+ statuses = [
+ reconcile_operation_state(
+ operation.kind,
+ operation.expected_state,
+ operation.desired_state,
+ graph_write_adapter.delete_reader(plan, operation),
+ )
+ for _ in range(3)
+ ]
+
+ assert statuses == [
+ ApplyStatus.APPLIED,
+ ApplyStatus.RETRYABLE_NOT_APPLIED,
+ ApplyStatus.CONFLICT,
+ ]
+
+
+def test_delete_edge_dry_run_plan_id_routes_through_canonical_executor(monkeypatch):
+ monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
+ monkeypatch.setattr(
+ "hugegraph_mcp.tools.manage_graph_data._fetch_live_schema",
+ _schema,
+ )
+ state = {"exists": True}
+
+ def fake_read(query):
+ if query.endswith(".count()"):
+ data = [1]
+ elif query.endswith(".limit(2).id()"):
+ data = ["edge-id"]
+ elif query == "g.E('edge-id').limit(2).elementMap()":
+ data = [{"id": "edge-id", "label": "knows"}] if state["exists"] else []
+ else:
+ data = []
+ return {"ok": True, "data": {"data": data}}
+
+ def fake_write(query, **_kwargs):
+ assert query == "g.E('edge-id').drop()"
+ state["exists"] = False
+ return {"ok": True, "data": {"affected": 1}}
+
+ monkeypatch.setattr(graph_write_adapter.gremlin_tools, "execute_gremlin_read", fake_read)
+ monkeypatch.setattr(graph_write_adapter.gremlin_tools, "execute_gremlin_write", fake_write)
+ preview = manage_graph_data(
+ mode="delete",
+ change_plan={
+ "operations": [
+ {
+ "op": "delete_edge",
+ "label": "knows",
+ "source_label": "person",
+ "target_label": "person",
+ "source_match": {"name": "Alice"},
+ "target_match": {"name": "Bob"},
+ }
+ ]
+ },
+ )
+
+ result = confirm_write(preview["data"]["plan_id"])
+
+ assert preview["data"]["confirmable"] is True
+ assert result["ok"] is True
+ assert result["data"]["status"] == "APPLIED"
+ assert result["data"]["operations"][0]["receipt"]["observed_state"]["id"] == "edge-id"
+
+
+def graph_write_adapter_registry():
+ from hugegraph_mcp.write_executor import WriteExecutorRegistry
+
+ registry = WriteExecutorRegistry()
+ registry.register(CREATE_VERTEX, graph_write_adapter.create_vertex_adapter)
+ registry.register(CREATE_EDGE, graph_write_adapter.create_edge_adapter)
+ return registry
diff --git a/hugegraph-mcp/tests/test_ingest_graph_data.py b/hugegraph-mcp/tests/test_ingest_graph_data.py
index dfc7b54e..5e46d5bb 100644
--- a/hugegraph-mcp/tests/test_ingest_graph_data.py
+++ b/hugegraph-mcp/tests/test_ingest_graph_data.py
@@ -11,13 +11,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import json
import re
from unittest.mock import Mock
import pytest
-
-from hugegraph_mcp import server
from hugegraph_mcp.envelope import envelope_ok
from hugegraph_mcp.tools import ingest_graph_data as ingest_graph_data_module
from hugegraph_mcp.tools import manage_graph_data as manage_graph_data_module
@@ -64,9 +61,7 @@ def _live_schema():
def _mock_schema(monkeypatch):
- monkeypatch.setattr(
- ingest_graph_data_module, "_fetch_live_schema", lambda: _live_schema()
- )
+ monkeypatch.setattr(ingest_graph_data_module, "_fetch_live_schema", lambda: _live_schema())
def test_ingest_graph_data_accepts_string_property_schema(monkeypatch):
@@ -114,12 +109,8 @@ def test_ingest_graph_data_dry_run_same_input_same_hash(monkeypatch):
monkeypatch.setattr("hugegraph_mcp.plan_hash.time.time", lambda: 1000)
# Same nonce + same payload + same expiry window = same hash.
- first = ingest_graph_data_module.ingest_graph_data(
- _graph_data(), nonce="fixed_nonce"
- )
- second = ingest_graph_data_module.ingest_graph_data(
- _graph_data(), nonce="fixed_nonce"
- )
+ first = ingest_graph_data_module.ingest_graph_data(_graph_data(), nonce="fixed_nonce")
+ second = ingest_graph_data_module.ingest_graph_data(_graph_data(), nonce="fixed_nonce")
assert first["data"]["plan_hash"] == second["data"]["plan_hash"]
@@ -131,9 +122,7 @@ def test_ingest_graph_data_plan_hash_includes_schema(monkeypatch):
schema_with_age_text["schema"]["propertykeys"][1]["data_type"] = "TEXT"
first = ingest_graph_data_module.calculate_plan_hash(graph_data, schema)
- second = ingest_graph_data_module.calculate_plan_hash(
- graph_data, schema_with_age_text
- )
+ second = ingest_graph_data_module.calculate_plan_hash(graph_data, schema_with_age_text)
assert first != second
@@ -142,9 +131,7 @@ def test_ingest_plan_hash_schema_field_order_same_hash():
graph_data = _graph_data()
schema = _live_schema()
reordered_schema = _live_schema()
- reordered_schema["schema"]["propertykeys"] = list(
- reversed(reordered_schema["schema"]["propertykeys"])
- )
+ reordered_schema["schema"]["propertykeys"] = list(reversed(reordered_schema["schema"]["propertykeys"]))
reordered_schema["schema"]["vertexlabels"][0]["properties"] = [
{"name": "age"},
{"name": "name"},
@@ -156,6 +143,27 @@ def test_ingest_plan_hash_schema_field_order_same_hash():
assert first == second
+def test_ingest_plan_hash_composite_primary_key_vertex_order_same_hash():
+ schema = _live_schema()
+ schema["schema"]["vertexlabels"][0]["primary_keys"] = ["name", "age"]
+ graph_data = {
+ "vertices": [
+ {"label": "person", "properties": {"name": "Alice", "age": 42}},
+ {"label": "person", "properties": {"name": "Alice", "age": 31}},
+ ],
+ "edges": [],
+ }
+ reordered_graph_data = {
+ "vertices": list(reversed(graph_data["vertices"])),
+ "edges": [],
+ }
+
+ first = ingest_graph_data_module.calculate_plan_hash(graph_data, schema)
+ second = ingest_graph_data_module.calculate_plan_hash(reordered_graph_data, schema)
+
+ assert first == second
+
+
def test_ingest_plan_hash_schema_primary_key_change_different_hash():
graph_data = _graph_data()
schema = _live_schema()
@@ -205,9 +213,7 @@ def test_ingest_plan_hash_schema_id_strategy_change_different_hash():
),
],
)
-def test_validate_graph_payload_rejects_invalid_customize_id(
- id_strategy, vertex_id, expected_error
-):
+def test_validate_graph_payload_rejects_invalid_customize_id(id_strategy, vertex_id, expected_error):
schema = _live_schema()
vertex_label = schema["schema"]["vertexlabels"][0]
vertex_label["id_strategy"] = id_strategy
@@ -216,9 +222,7 @@ def test_validate_graph_payload_rejects_invalid_customize_id(
if vertex_id is not None:
vertex["id"] = vertex_id
- result = ingest_graph_data_module.validate_graph_payload(
- {"vertices": [vertex], "edges": []}, live_schema=schema
- )
+ result = ingest_graph_data_module.validate_graph_payload({"vertices": [vertex], "edges": []}, live_schema=schema)
assert result["valid"] is False
assert any(expected_error in error for error in result["errors"])
@@ -235,15 +239,11 @@ def test_validate_graph_payload_rejects_invalid_customize_id(
def test_validate_graph_payload_accepts_uuid_property(cardinality, value):
schema = _live_schema()
schema["schema"]["vertexlabels"][0]["properties"].append({"name": "uid"})
- schema["schema"]["propertykeys"].append(
- {"name": "uid", "data_type": "UUID", "cardinality": cardinality}
- )
+ schema["schema"]["propertykeys"].append({"name": "uid", "data_type": "UUID", "cardinality": cardinality})
result = ingest_graph_data_module.validate_graph_payload(
{
- "vertices": [
- {"label": "person", "properties": {"name": "Alice", "uid": value}}
- ],
+ "vertices": [{"label": "person", "properties": {"name": "Alice", "uid": value}}],
"edges": [],
},
live_schema=schema,
@@ -255,14 +255,32 @@ def test_validate_graph_payload_accepts_uuid_property(cardinality, value):
def test_validate_graph_payload_rejects_non_string_uuid():
schema = _live_schema()
schema["schema"]["vertexlabels"][0]["properties"].append({"name": "uid"})
- schema["schema"]["propertykeys"].append(
- {"name": "uid", "data_type": "UUID", "cardinality": "SINGLE"}
+ schema["schema"]["propertykeys"].append({"name": "uid", "data_type": "UUID", "cardinality": "SINGLE"})
+
+ result = ingest_graph_data_module.validate_graph_payload(
+ {
+ "vertices": [{"label": "person", "properties": {"name": "Alice", "uid": 1}}],
+ "edges": [],
+ },
+ live_schema=schema,
)
+ assert result["valid"] is False
+ assert any("expects UUID, got int" in error for error in result["errors"])
+
+
+def test_validate_graph_payload_rejects_malformed_uuid():
+ schema = _live_schema()
+ schema["schema"]["vertexlabels"][0]["properties"].append({"name": "uid"})
+ schema["schema"]["propertykeys"].append({"name": "uid", "data_type": "UUID", "cardinality": "SINGLE"})
+
result = ingest_graph_data_module.validate_graph_payload(
{
"vertices": [
- {"label": "person", "properties": {"name": "Alice", "uid": 1}}
+ {
+ "label": "person",
+ "properties": {"name": "Alice", "uid": "not-a-uuid"},
+ }
],
"edges": [],
},
@@ -270,7 +288,49 @@ def test_validate_graph_payload_rejects_non_string_uuid():
)
assert result["valid"] is False
- assert any("expects UUID, got int" in error for error in result["errors"])
+ assert any("expects UUID, got str" in error for error in result["errors"])
+
+
+def test_validate_graph_payload_rejects_out_of_range_integer():
+ schema = _live_schema()
+
+ result = ingest_graph_data_module.validate_graph_payload(
+ {
+ "vertices": [
+ {
+ "label": "person",
+ "properties": {"name": "Alice", "age": 2**31},
+ }
+ ],
+ "edges": [],
+ },
+ live_schema=schema,
+ )
+
+ assert result["valid"] is False
+ assert any("expects INT, got int" in error for error in result["errors"])
+
+
+def test_validate_graph_payload_rejects_double_overflow_without_raising():
+ schema = _live_schema()
+ schema["schema"]["vertexlabels"][0]["properties"].append({"name": "weight"})
+ schema["schema"]["propertykeys"].append({"name": "weight", "data_type": "DOUBLE", "cardinality": "SINGLE"})
+
+ result = ingest_graph_data_module.validate_graph_payload(
+ {
+ "vertices": [
+ {
+ "label": "person",
+ "properties": {"name": "Alice", "weight": 10**1000},
+ }
+ ],
+ "edges": [],
+ },
+ live_schema=schema,
+ )
+
+ assert result["valid"] is False
+ assert "vertex 0 property 'weight' expects DOUBLE, got int" in result["errors"]
@pytest.mark.parametrize(
@@ -310,9 +370,7 @@ def test_ingest_plan_hash_schema_metadata_ignored_same_hash():
schema_with_metadata["server_time"] = "2026-05-26T00:00:00Z"
first = ingest_graph_data_module.calculate_plan_hash(graph_data, schema)
- second = ingest_graph_data_module.calculate_plan_hash(
- graph_data, schema_with_metadata
- )
+ second = ingest_graph_data_module.calculate_plan_hash(graph_data, schema_with_metadata)
assert first == second
@@ -357,9 +415,7 @@ def test_ingest_graph_data_validate_invalid(monkeypatch):
def test_ingest_graph_data_rejects_when_live_schema_unavailable(monkeypatch):
monkeypatch.setattr(ingest_graph_data_module, "_fetch_live_schema", lambda: None)
- result = ingest_graph_data_module.ingest_graph_data(
- {"vertices": [{"label": "x"}], "edges": []}
- )
+ result = ingest_graph_data_module.ingest_graph_data({"vertices": [{"label": "x"}], "edges": []})
assert result["ok"] is False
assert result["error"]["type"] == "CONNECTION_FAILED"
@@ -385,9 +441,7 @@ def test_ingest_graph_data_schema_mismatch(monkeypatch):
assert result["ok"] is False
assert result["error"]["type"] == "SCHEMA_MISMATCH"
- assert any(
- "source_label 'ghost'" in e for e in result["error"]["details"]["errors"]
- )
+ assert any("source_label 'ghost'" in e for e in result["error"]["details"]["errors"])
def test_validate_graph_payload_rejects_labels_when_live_schema_is_empty():
@@ -435,9 +489,7 @@ def test_ingest_graph_data_rejects_property_type_mismatch(monkeypatch):
assert result["ok"] is False
assert result["error"]["type"] == "SCHEMA_MISMATCH"
- assert any(
- "property 'age' expects INT" in e for e in result["error"]["details"]["errors"]
- )
+ assert any("property 'age' expects INT" in e for e in result["error"]["details"]["errors"])
def _collection_schema():
@@ -502,9 +554,7 @@ def test_ingest_graph_data_accepts_vertex_list_and_edge_set_properties(monkeypat
assert result["data"]["mutation_summary"] == {"vertices": 2, "edges": 1}
-@pytest.mark.parametrize(
- "property_keys_field", ["propertykeys", "property_keys", "propertyKeys"]
-)
+@pytest.mark.parametrize("property_keys_field", ["propertykeys", "property_keys", "propertyKeys"])
def test_validate_graph_payload_accepts_property_key_collection_aliases(
property_keys_field,
):
@@ -549,10 +599,7 @@ def test_validate_graph_payload_preserves_top_level_none_but_rejects_none_elemen
assert allowed["valid"] is True
assert rejected["valid"] is False
- assert (
- "vertex 0 property 'aliases' element 1 expects TEXT, got NoneType"
- in rejected["errors"]
- )
+ assert "vertex 0 property 'aliases' element 1 expects TEXT, got NoneType" in rejected["errors"]
assert all("Al" not in error for error in rejected["errors"])
@@ -563,14 +610,10 @@ def test_validate_graph_payload_preserves_top_level_none_but_rejects_none_elemen
("data_type", "DECIMAL", "unsupported data_type 'DECIMAL'"),
],
)
-def test_validate_graph_payload_rejects_unsupported_property_spec(
- field, value, expected
-):
+def test_validate_graph_payload_rejects_unsupported_property_spec(field, value, expected):
schema = _collection_schema()
aliases = next(
- item
- for item in schema["schema"]["propertykeys"]
- if (item.get("name") or item.get("propertyName")) == "aliases"
+ item for item in schema["schema"]["propertykeys"] if (item.get("name") or item.get("propertyName")) == "aliases"
)
aliases[field] = value
if field == "cardinality":
@@ -614,9 +657,7 @@ def test_validate_graph_payload_rejects_scalar_for_collection_property():
)
assert result["valid"] is False
- assert (
- "vertex 0 property 'aliases' expects LIST of TEXT, got str" in result["errors"]
- )
+ assert "vertex 0 property 'aliases' expects LIST of TEXT, got str" in result["errors"]
def test_validate_graph_payload_rejects_tuple_for_collection_property():
@@ -629,10 +670,7 @@ def test_validate_graph_payload_rejects_tuple_for_collection_property():
)
assert result["valid"] is False
- assert (
- "vertex 0 property 'aliases' expects LIST of TEXT, got tuple"
- in result["errors"]
- )
+ assert "vertex 0 property 'aliases' expects LIST of TEXT, got tuple" in result["errors"]
def test_validate_graph_payload_rejects_invalid_collection_element_without_value():
@@ -659,9 +697,7 @@ def test_validate_graph_payload_rejects_bool_in_int_collection():
)
assert result["valid"] is False
- assert (
- "vertex 0 property 'scores' element 1 expects INT, got bool" in result["errors"]
- )
+ assert "vertex 0 property 'scores' element 1 expects INT, got bool" in result["errors"]
def test_validate_graph_payload_accepts_boolean_collection_and_rejects_wrong_element():
@@ -679,10 +715,7 @@ def test_validate_graph_payload_accepts_boolean_collection_and_rejects_wrong_ele
assert valid["valid"] is True
assert invalid["valid"] is False
- assert (
- "vertex 0 property 'flags' element 1 expects BOOLEAN, got int"
- in invalid["errors"]
- )
+ assert "vertex 0 property 'flags' element 1 expects BOOLEAN, got int" in invalid["errors"]
def test_validate_graph_payload_rejects_list_for_single_property():
@@ -708,16 +741,25 @@ def test_manage_graph_data_import_accepts_collections_in_dry_run(monkeypatch):
monkeypatch.setattr(
manage_graph_data_module.gremlin_tools,
"execute_gremlin_read",
- lambda _query: {"data": [0], "total": 1, "is_read": True},
+ lambda query: {
+ "data": [] if ".id()" in query else [0],
+ "total": 0 if ".id()" in query else 1,
+ "is_read": True,
+ },
)
+ graph_data = _collection_graph_data()
+ graph_data["vertices"][0]["id"] = "alice"
+ graph_data["vertices"][1]["id"] = "bob"
result = manage_graph_data_module.manage_graph_data(
mode="import",
- graph_data=_collection_graph_data(),
+ graph_data=graph_data,
)
assert result["ok"] is True
- assert result["data"]["confirmable"] is True
+ assert result["data"]["confirmable"] is False
+ assert "plan_id" not in result["data"]
+ assert len(result["data"]["workflow_preview"]["operations"]) == 3
assert result["data"]["plan_hash"]
assert result["data"]["mutation_summary"] == {
"create_edge": 1,
@@ -725,7 +767,7 @@ def test_manage_graph_data_import_accepts_collections_in_dry_run(monkeypatch):
}
-def test_public_import_graph_data_rejects_invalid_collection_before_execute(
+def test_manage_import_rejects_invalid_collection_before_execute(
monkeypatch,
):
graph_data = _collection_graph_data()
@@ -738,20 +780,14 @@ def test_public_import_graph_data_rejects_invalid_collection_before_execute(
)
monkeypatch.setattr(manage_graph_data_module, "execute_graph_change_plan", execute)
- result = server.import_graph_data_tool(
- mode="ingest",
+ result = manage_graph_data_module.manage_graph_data(
+ mode="import",
graph_data=graph_data,
- dry_run=False,
- confirm=True,
)
assert result["ok"] is False
assert result["error"]["type"] == "SCHEMA_MISMATCH"
- assert result["error"]["source"] == "import_graph_data_tool"
- assert (
- "vertex 0 property 'aliases' expects LIST of TEXT, got str"
- in result["error"]["details"]["errors"]
- )
+ assert "vertex 0 property 'aliases' expects LIST of TEXT, got str" in result["error"]["details"]["errors"]
execute.assert_not_called()
@@ -765,8 +801,7 @@ def test_ingest_graph_data_rejects_missing_schema_primary_key(monkeypatch):
assert result["ok"] is False
assert result["error"]["type"] == "SCHEMA_MISMATCH"
assert any(
- "vertex 0 missing primary key value for label 'person': name" in e
- for e in result["error"]["details"]["errors"]
+ "vertex 0 missing primary key value for label 'person': name" in e for e in result["error"]["details"]["errors"]
)
@@ -858,6 +893,26 @@ def test_validate_graph_payload_rejects_mixed_target_endpoint_forms():
assert any("mixes target and inV endpoint forms" in e for e in result["errors"])
+def test_validate_graph_payload_rejects_edge_with_both_endpoints_missing():
+ result = ingest_graph_data_module.validate_graph_payload(
+ {
+ "vertices": [],
+ "edges": [
+ {
+ "label": "knows",
+ "source_label": "person",
+ "target_label": "person",
+ }
+ ],
+ },
+ live_schema=_live_schema(),
+ )
+
+ assert result["valid"] is False
+ assert "edge 0 missing source endpoint" in result["errors"]
+ assert "edge 0 missing target endpoint" in result["errors"]
+
+
def test_ingest_graph_data_rejects_edge_endpoint_missing_primary_key(monkeypatch):
_mock_schema(monkeypatch)
@@ -913,9 +968,7 @@ def test_validate_graph_payload_rejects_ambiguous_scalar_endpoint():
)
assert result["valid"] is False
- assert any(
- "source scalar endpoint is ambiguous" in error for error in result["errors"]
- )
+ assert any("source scalar endpoint is ambiguous" in error for error in result["errors"])
def test_ingest_graph_data_dry_run_rejects_too_many_operations_before_plan(
@@ -923,9 +976,7 @@ def test_ingest_graph_data_dry_run_rejects_too_many_operations_before_plan(
):
_mock_schema(monkeypatch)
graph_data = {
- "vertices": [
- {"label": "person", "properties": {"name": f"n{idx}"}} for idx in range(201)
- ],
+ "vertices": [{"label": "person", "properties": {"name": f"n{idx}"}} for idx in range(201)],
"edges": [],
}
@@ -1020,9 +1071,7 @@ def test_ingest_graph_data_rejects_edge_label_mismatch(monkeypatch):
errors = result["error"]["details"]["errors"]
assert any("edge 0 label 'likes' does not exist in schema" in e for e in errors)
assert any("edge 1 target_label 'ghost'" in e for e in errors)
- assert any(
- "does not match edge label 'knows' target_label 'person'" in e for e in errors
- )
+ assert any("does not match edge label 'knows' target_label 'person'" in e for e in errors)
def test_ingest_graph_data_warns_for_labels_without_schema_index(monkeypatch):
@@ -1035,9 +1084,7 @@ def test_ingest_graph_data_warns_for_labels_without_schema_index(monkeypatch):
result = ingest_graph_data_module.ingest_graph_data(_graph_data())
assert result["ok"] is True
- assert (
- "no edge index found in schema for label: knows" in result["data"]["warnings"]
- )
+ assert "no edge index found in schema for label: knows" in result["data"]["warnings"]
def test_ingest_graph_data_missing_confirm(monkeypatch):
@@ -1071,7 +1118,7 @@ def test_ingest_graph_data_plan_hash_mismatch(monkeypatch):
)
assert result["ok"] is False
- assert result["error"]["type"] == "PLAN_HASH_MISMATCH"
+ assert result["error"]["type"] == "FEATURE_DISABLED"
def test_ingest_graph_data_plan_hash_expired(monkeypatch):
@@ -1091,7 +1138,7 @@ def test_ingest_graph_data_plan_hash_expired(monkeypatch):
)
assert result["ok"] is False
- assert result["error"]["type"] == "PLAN_EXPIRED"
+ assert result["error"]["type"] == "FEATURE_DISABLED"
def test_ingest_graph_data_readonly(monkeypatch):
@@ -1113,7 +1160,7 @@ def test_ingest_graph_data_readonly_preview_does_not_issue_plan(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true")
issue = Mock()
- monkeypatch.setattr(ingest_graph_data_module, "issue_plan", issue)
+ monkeypatch.setattr(ingest_graph_data_module, "issue_plan", issue, raising=False)
result = ingest_graph_data_module.ingest_graph_data(_graph_data())
@@ -1126,12 +1173,8 @@ def test_ingest_graph_data_readonly_preview_does_not_issue_plan(monkeypatch):
def test_ingest_graph_data_success(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
- post = Mock(
- return_value=envelope_ok(
- {"ok": True, "data": {"written": {"vertices": 2, "edges": 1}}}
- )
- )
- monkeypatch.setattr(ingest_graph_data_module, "post", post)
+ post = Mock(return_value=envelope_ok({"ok": True, "data": {"written": {"vertices": 2, "edges": 1}}}))
+ monkeypatch.setattr(ingest_graph_data_module, "post", post, raising=False)
graph_data = _graph_data()
dry_run = ingest_graph_data_module.ingest_graph_data(graph_data)
@@ -1146,37 +1189,18 @@ def test_ingest_graph_data_success(monkeypatch):
expires_at=plan_ctx["expires_at"],
)
- assert result["ok"] is True
- assert result["data"]["batch_id"].startswith("batch-")
- assert result["data"]["status"] == "success"
- assert result["data"]["planned"] == {"vertices": 2, "edges": 1}
- assert result["data"]["written"] == {"vertices": 2, "edges": 1}
- post.assert_called_once()
- assert post.call_args.args == ("/graph-import",)
- assert post.call_args.kwargs["json"]["schema"] == "hugegraph"
- import_payload = json.loads(post.call_args.kwargs["json"]["data"])
- assert import_payload["vertices"][0]["id"] == "1:Alice"
- assert import_payload["vertices"][1]["id"] == "1:Bob"
- assert import_payload["edges"][0]["outV"] == "1:Alice"
- assert import_payload["edges"][0]["outVLabel"] == "person"
- assert import_payload["edges"][0]["inV"] == "1:Bob"
- assert import_payload["edges"][0]["inVLabel"] == "person"
- assert import_payload["edges"][0]["properties"] == {}
+ assert result["ok"] is False
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ post.assert_not_called()
def test_ingest_graph_data_replayed_confirmation_does_not_post_twice(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
- post = Mock(
- return_value=envelope_ok(
- {"ok": True, "data": {"written": {"vertices": 2, "edges": 1}}}
- )
- )
- monkeypatch.setattr(ingest_graph_data_module, "post", post)
+ post = Mock(return_value=envelope_ok({"ok": True, "data": {"written": {"vertices": 2, "edges": 1}}}))
+ monkeypatch.setattr(ingest_graph_data_module, "post", post, raising=False)
graph_data = _graph_data()
- dry_run = ingest_graph_data_module.ingest_graph_data(
- graph_data, nonce="ingest-replay"
- )
+ dry_run = ingest_graph_data_module.ingest_graph_data(graph_data, nonce="ingest-replay")
context = dry_run["data"]["plan_context"]
arguments = {
"graph_data": graph_data,
@@ -1190,23 +1214,20 @@ def test_ingest_graph_data_replayed_confirmation_does_not_post_twice(monkeypatch
first = ingest_graph_data_module.ingest_graph_data(**arguments)
second = ingest_graph_data_module.ingest_graph_data(**arguments)
- assert first["ok"] is True
+ assert first["ok"] is False
+ assert first["error"]["type"] == "FEATURE_DISABLED"
assert second["ok"] is False
- assert second["error"]["type"] == "PLAN_ALREADY_USED"
- assert "already been used" in second["error"]["message"]
- assert "Inspect the current target state" in second["error"]["suggestion"]
- post.assert_called_once()
+ assert second["error"]["type"] == "FEATURE_DISABLED"
+ post.assert_not_called()
def test_ingest_partial_apply_consumes_confirmation(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
post = Mock(return_value=envelope_ok({"inserted": 2}))
- monkeypatch.setattr(ingest_graph_data_module, "post", post)
+ monkeypatch.setattr(ingest_graph_data_module, "post", post, raising=False)
graph_data = _graph_data()
- dry_run = ingest_graph_data_module.ingest_graph_data(
- graph_data, nonce="ingest-partial"
- )
+ dry_run = ingest_graph_data_module.ingest_graph_data(graph_data, nonce="ingest-partial")
context = dry_run["data"]["plan_context"]
arguments = {
"graph_data": graph_data,
@@ -1221,18 +1242,19 @@ def test_ingest_partial_apply_consumes_confirmation(monkeypatch):
replay = ingest_graph_data_module.ingest_graph_data(**arguments)
assert partial["ok"] is False
- assert partial["error"]["details"]["status"] == "partial"
+ assert partial["error"]["type"] == "FEATURE_DISABLED"
assert partial["error"]["retryable"] is False
assert replay["ok"] is False
- assert replay["error"]["type"] == "PLAN_ALREADY_USED"
- post.assert_called_once()
+ assert replay["error"]["type"] == "FEATURE_DISABLED"
+ assert replay["error"]["retryable"] is False
+ post.assert_not_called()
def test_ingest_graph_data_degrades_when_ai_omits_counts(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
post = Mock(return_value=envelope_ok({"message": "import finished"}))
- monkeypatch.setattr(ingest_graph_data_module, "post", post)
+ monkeypatch.setattr(ingest_graph_data_module, "post", post, raising=False)
graph_data = _graph_data()
dry_run = ingest_graph_data_module.ingest_graph_data(graph_data)
plan_ctx = dry_run["data"]["plan_context"]
@@ -1247,19 +1269,16 @@ def test_ingest_graph_data_degrades_when_ai_omits_counts(monkeypatch):
)
assert result["ok"] is False
- assert result["error"]["type"] == "FLOW_EXECUTION_FAILED"
- details = result["error"]["details"]
- assert details["status"] == "degraded"
- assert details["written"] == {"vertices": 0, "edges": 0}
+ assert result["error"]["type"] == "FEATURE_DISABLED"
assert result["error"]["retryable"] is False
- assert any("write outcome is unknown" in w for w in result["warnings"])
+ post.assert_not_called()
def test_ingest_graph_data_splits_total_written_count(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
post = Mock(return_value=envelope_ok({"inserted": 2}))
- monkeypatch.setattr(ingest_graph_data_module, "post", post)
+ monkeypatch.setattr(ingest_graph_data_module, "post", post, raising=False)
graph_data = _graph_data()
dry_run = ingest_graph_data_module.ingest_graph_data(graph_data)
plan_ctx = dry_run["data"]["plan_context"]
@@ -1274,16 +1293,15 @@ def test_ingest_graph_data_splits_total_written_count(monkeypatch):
)
assert result["ok"] is False
- assert result["error"]["details"]["status"] == "partial"
- assert result["error"]["details"]["written"] == {"vertices": 2, "edges": 0}
- assert any("total written count" in w for w in result["warnings"])
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ post.assert_not_called()
def test_ingest_graph_data_does_not_promote_ai_failure_without_counts(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
post = Mock(return_value=envelope_ok({"success": False, "status": "partial"}))
- monkeypatch.setattr(ingest_graph_data_module, "post", post)
+ monkeypatch.setattr(ingest_graph_data_module, "post", post, raising=False)
graph_data = _graph_data()
dry_run = ingest_graph_data_module.ingest_graph_data(graph_data)
plan_ctx = dry_run["data"]["plan_context"]
@@ -1298,15 +1316,50 @@ def test_ingest_graph_data_does_not_promote_ai_failure_without_counts(monkeypatc
)
assert result["ok"] is False
- assert result["error"]["details"]["status"] == "error"
- assert result["error"]["details"]["written"] == {"vertices": 0, "edges": 0}
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ post.assert_not_called()
+
+
+def test_ingest_graph_data_maps_proven_zero_writes_to_rejected(monkeypatch):
+ _mock_schema(monkeypatch)
+ monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
+ post = Mock(
+ return_value=envelope_ok(
+ {
+ "success": False,
+ "written": {"vertices": 0, "edges": 0},
+ }
+ )
+ )
+ monkeypatch.setattr(
+ ingest_graph_data_module,
+ "post",
+ post,
+ raising=False,
+ )
+ graph_data = _graph_data()
+ dry_run = ingest_graph_data_module.ingest_graph_data(graph_data)
+ context = dry_run["data"]["plan_context"]
+
+ result = ingest_graph_data_module.ingest_graph_data(
+ graph_data,
+ dry_run=False,
+ confirm=True,
+ plan_hash=dry_run["data"]["plan_hash"],
+ nonce=context["nonce"],
+ expires_at=context["expires_at"],
+ )
+
+ assert result["ok"] is False
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ post.assert_not_called()
def test_ingest_graph_data_does_not_promote_failed_items_without_counts(monkeypatch):
_mock_schema(monkeypatch)
monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false")
post = Mock(return_value=envelope_ok({"failed_items": [{"index": 0}]}))
- monkeypatch.setattr(ingest_graph_data_module, "post", post)
+ monkeypatch.setattr(ingest_graph_data_module, "post", post, raising=False)
graph_data = _graph_data()
dry_run = ingest_graph_data_module.ingest_graph_data(graph_data)
plan_ctx = dry_run["data"]["plan_context"]
@@ -1321,5 +1374,5 @@ def test_ingest_graph_data_does_not_promote_failed_items_without_counts(monkeypa
)
assert result["ok"] is False
- assert result["error"]["details"]["status"] == "degraded"
- assert result["error"]["details"]["failed_items"] == [{"index": 0}]
+ assert result["error"]["type"] == "FEATURE_DISABLED"
+ post.assert_not_called()
diff --git a/hugegraph-mcp/tests/test_manage_graph_data.py b/hugegraph-mcp/tests/test_manage_graph_data.py
index be0d4d57..a976b7a2 100644
--- a/hugegraph-mcp/tests/test_manage_graph_data.py
+++ b/hugegraph-mcp/tests/test_manage_graph_data.py
@@ -19,6 +19,9 @@ from hugegraph_mcp.tools import manage_graph_data as manage_graph_data_module
from hugegraph_mcp.tools.graph_data_gremlin import (
_create_edge_query,
_create_vertex_query,
+ _delete_edge_query,
+ _delete_target_query,
+ _delete_vertex_query,
_g,
)
@@ -113,6 +116,46 @@ def test_validate_graph_change_plan_rejects_unknown_op():
assert "unsupported op" in result["errors"][0]["reason"]
+def test_create_vertex_plan_uses_shared_property_type_validation():
+ result = manage_graph_data_module.validate_graph_change_plan(
+ {
+ "operations": [
+ {
+ "op": "create_vertex",
+ "label": "person",
+ "properties": {"name": "Alice", "age": 2**31},
+ }
+ ]
+ },
+ _live_schema(),
+ )
+
+ assert result["valid"] is False
+ assert "vertex 0 property 'age' expects INT, got int" in result["errors"][0]["reason"]
+
+
+def test_create_edge_plan_uses_shared_property_type_validation():
+ result = manage_graph_data_module.validate_graph_change_plan(
+ {
+ "operations": [
+ {
+ "op": "create_edge",
+ "label": "knows",
+ "source_label": "person",
+ "source_match": {"name": "Alice"},
+ "target_label": "person",
+ "target_match": {"name": "Bob"},
+ "properties": {"since": "recently"},
+ }
+ ]
+ },
+ _live_schema(),
+ )
+
+ assert result["valid"] is False
+ assert "edge 0 property 'since' expects INT, got str" in result["errors"][0]["reason"]
+
+
def test_public_import_dry_run_rejects_customize_string_vertex_without_id(
monkeypatch,
):
@@ -125,9 +168,7 @@ def test_public_import_dry_run_rejects_customize_string_vertex_without_id(
result = manage_graph_data_module.manage_graph_data(
mode="import",
graph_data={
- "vertices": [
- {"label": "person", "properties": {"name": "Alice", "age": 30}}
- ],
+ "vertices": [{"label": "person", "properties": {"name": "Alice", "age": 30}}],
"edges": [],
},
dry_run=True,
@@ -135,9 +176,7 @@ def test_public_import_dry_run_rejects_customize_string_vertex_without_id(
assert result["ok"] is False
assert result["error"]["type"] == "SCHEMA_MISMATCH"
- assert result["error"]["details"]["errors"] == [
- "vertex 0 missing required id for CUSTOMIZE_STRING label 'person'"
- ]
+ assert result["error"]["details"]["errors"] == ["vertex 0 missing required id for CUSTOMIZE_STRING label 'person'"]
def test_public_import_dry_run_rejects_customize_number_vertex_with_bool_id(
@@ -234,23 +273,22 @@ def test_validate_edge_rejects_unknown_endpoint_label():
)
assert result["valid"] is False
- assert any(
- "source_label references undefined" in e["reason"] for e in result["errors"]
- )
+ assert any("source_label references undefined" in e["reason"] for e in result["errors"])
def test_dry_run_delete_edge_rejects_non_single_match(monkeypatch):
- counts = iter([1, 1, 2])
+ def fake_read(query):
+ return {
+ "data": ["edge-1", "edge-2"] if query.endswith(".id()") else [1],
+ "total": 1,
+ "duration_ms": 1,
+ "is_read": True,
+ }
monkeypatch.setattr(
manage_graph_data_module.gremlin_tools,
"execute_gremlin_read",
- lambda _query: {
- "data": [next(counts)],
- "total": 1,
- "duration_ms": 1,
- "is_read": True,
- },
+ fake_read,
)
result = manage_graph_data_module.dry_run_graph_change_plan(
@@ -264,20 +302,17 @@ def test_dry_run_delete_edge_rejects_non_single_match(monkeypatch):
def test_dry_run_delete_edge_returns_preview_and_hash(monkeypatch):
... 5451 lines suppressed ...